From b21f197c090d3d94a069486c9959466fbdfe96ce Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 18:31:02 +0700 Subject: [PATCH] feat(notifications): add Zalo OA webhook controller + WebSocket gateway tests - Add ZaloOaWebhookController: GET verification endpoint, POST event handler for follow/unfollow/user_send_text events with user linking via OAuthAccount - Register webhook controller in NotificationsModule - Add 13 unit tests for webhook (challenge verify, follow/unfollow/message handling, linked/unlinked users, error resilience) - Add 18 unit tests for NotificationsGateway (JWT auth, multi-device tracking, disconnect cleanup, notification.sent event, Redis cache, unread count) Co-Authored-By: Paperclip --- .../notifications/notifications.module.ts | 3 +- .../__tests__/notifications.gateway.spec.ts | 276 ++++++++++++++++++ .../zalo-oa-webhook.controller.spec.ts | 225 ++++++++++++++ .../controllers/zalo-oa-webhook.controller.ts | 166 +++++++++++ 4 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/modules/notifications/presentation/__tests__/notifications.gateway.spec.ts create mode 100644 apps/api/src/modules/notifications/presentation/__tests__/zalo-oa-webhook.controller.spec.ts create mode 100644 apps/api/src/modules/notifications/presentation/controllers/zalo-oa-webhook.controller.ts diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts index b987c0b..d483391 100644 --- a/apps/api/src/modules/notifications/notifications.module.ts +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -27,6 +27,7 @@ import { StringeeSmsService } from './infrastructure/services/stringee-sms.servi import { TemplateService } from './infrastructure/services/template.service'; import { ZaloOaService } from './infrastructure/services/zalo-oa.service'; import { NotificationsController } from './presentation/controllers/notifications.controller'; +import { ZaloOaWebhookController } from './presentation/controllers/zalo-oa-webhook.controller'; import { NotificationsGateway } from './presentation/gateways/notifications.gateway'; const CommandHandlers = [SendNotificationHandler]; @@ -51,7 +52,7 @@ const EventListeners = [ @Module({ imports: [CqrsModule, AuthModule], - controllers: [NotificationsController], + controllers: [NotificationsController, ZaloOaWebhookController], providers: [ // Repositories { provide: NOTIFICATION_REPOSITORY, useClass: PrismaNotificationRepository }, diff --git a/apps/api/src/modules/notifications/presentation/__tests__/notifications.gateway.spec.ts b/apps/api/src/modules/notifications/presentation/__tests__/notifications.gateway.spec.ts new file mode 100644 index 0000000..e301e69 --- /dev/null +++ b/apps/api/src/modules/notifications/presentation/__tests__/notifications.gateway.spec.ts @@ -0,0 +1,276 @@ +import type { NotificationSentEvent } from '../../domain/events/notification-sent.event'; +import { NotificationsGateway } from '../gateways/notifications.gateway'; + +function createMockSocket(overrides: Partial<{ + id: string; + data: Record; + handshake: { auth?: Record; headers?: Record; query?: Record }; + join: ReturnType; + emit: ReturnType; + disconnect: ReturnType; +}> = {}) { + return { + id: overrides.id ?? 'socket-1', + data: overrides.data ?? {}, + handshake: overrides.handshake ?? { auth: { token: 'valid-jwt' }, headers: {}, query: {} }, + join: overrides.join ?? vi.fn().mockResolvedValue(undefined), + emit: overrides.emit ?? vi.fn(), + disconnect: overrides.disconnect ?? vi.fn(), + } as any; +} + +describe('NotificationsGateway', () => { + let gateway: NotificationsGateway; + let mockTokenService: { verifyAccessToken: ReturnType }; + let mockLogger: { + log: ReturnType; + debug: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + let mockRedisService: { + isAvailable: ReturnType; + get: ReturnType; + set: ReturnType; + del: ReturnType; + getClient: ReturnType; + }; + let mockNotificationRepo: { countUnreadByUserId: ReturnType }; + let mockServer: { + to: ReturnType; + }; + + beforeEach(() => { + mockTokenService = { + verifyAccessToken: vi.fn().mockReturnValue({ sub: 'user-1', role: 'USER' }), + }; + mockLogger = { log: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + mockRedisService = { + isAvailable: vi.fn().mockReturnValue(true), + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + del: vi.fn().mockResolvedValue(undefined), + getClient: vi.fn().mockReturnValue({ exists: vi.fn().mockResolvedValue(0), incr: vi.fn() }), + }; + mockNotificationRepo = { countUnreadByUserId: vi.fn().mockResolvedValue(3) }; + + gateway = new NotificationsGateway( + mockTokenService as any, + mockLogger as any, + mockRedisService as any, + mockNotificationRepo as any, + ); + + // Wire the server mock + mockServer = { to: vi.fn().mockReturnValue({ emit: vi.fn() }) }; + (gateway as any).server = mockServer; + }); + + describe('afterInit', () => { + it('logs initialization', () => { + gateway.afterInit(); + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('initialized'), + 'NotificationsGateway', + ); + }); + }); + + describe('handleConnection', () => { + it('authenticates, joins room, and emits unread count', async () => { + const socket = createMockSocket(); + + await gateway.handleConnection(socket); + + expect(mockTokenService.verifyAccessToken).toHaveBeenCalledWith('valid-jwt'); + expect(socket.data['userId']).toBe('user-1'); + expect(socket.data['role']).toBe('USER'); + expect(socket.join).toHaveBeenCalledWith('user:user-1'); + expect(socket.emit).toHaveBeenCalledWith('notification:unread-count', { unreadCount: 3 }); + }); + + it('strips Bearer prefix from Authorization header', async () => { + const socket = createMockSocket({ + handshake: { auth: {}, headers: { authorization: 'Bearer my-token' }, query: {} }, + }); + + await gateway.handleConnection(socket); + + expect(mockTokenService.verifyAccessToken).toHaveBeenCalledWith('my-token'); + }); + + it('disconnects client when no token provided', async () => { + const socket = createMockSocket({ + handshake: { auth: {}, headers: {}, query: {} }, + }); + mockTokenService.verifyAccessToken.mockReturnValue(null); + + await gateway.handleConnection(socket); + + expect(socket.disconnect).toHaveBeenCalledWith(true); + }); + + it('disconnects client when token is invalid', async () => { + mockTokenService.verifyAccessToken.mockReturnValue(null); + const socket = createMockSocket(); + + await gateway.handleConnection(socket); + + expect(socket.disconnect).toHaveBeenCalledWith(true); + }); + + it('tracks multiple sockets per user (multi-device)', async () => { + const socket1 = createMockSocket({ id: 'sock-a' }); + const socket2 = createMockSocket({ id: 'sock-b' }); + + await gateway.handleConnection(socket1); + await gateway.handleConnection(socket2); + + // Both sockets tracked + const userSockets = (gateway as any).userSockets as Map>; + expect(userSockets.get('user-1')?.size).toBe(2); + expect(userSockets.get('user-1')?.has('sock-a')).toBe(true); + expect(userSockets.get('user-1')?.has('sock-b')).toBe(true); + }); + + it('uses cached unread count from Redis when available', async () => { + mockRedisService.get.mockResolvedValue('7'); + const socket = createMockSocket(); + + await gateway.handleConnection(socket); + + expect(socket.emit).toHaveBeenCalledWith('notification:unread-count', { unreadCount: 7 }); + expect(mockNotificationRepo.countUnreadByUserId).not.toHaveBeenCalled(); + }); + + it('falls back to DB when Redis unavailable', async () => { + mockRedisService.isAvailable.mockReturnValue(false); + const socket = createMockSocket(); + + await gateway.handleConnection(socket); + + expect(mockNotificationRepo.countUnreadByUserId).toHaveBeenCalledWith('user-1'); + expect(socket.emit).toHaveBeenCalledWith('notification:unread-count', { unreadCount: 3 }); + }); + }); + + describe('handleDisconnect', () => { + it('removes socket from tracking map', async () => { + const socket = createMockSocket({ id: 'sock-1' }); + await gateway.handleConnection(socket); + + gateway.handleDisconnect(socket); + + const userSockets = (gateway as any).userSockets as Map>; + expect(userSockets.has('user-1')).toBe(false); + }); + + it('keeps other sockets when one disconnects', async () => { + const socket1 = createMockSocket({ id: 'sock-1' }); + const socket2 = createMockSocket({ id: 'sock-2' }); + await gateway.handleConnection(socket1); + await gateway.handleConnection(socket2); + + gateway.handleDisconnect(socket1); + + const userSockets = (gateway as any).userSockets as Map>; + expect(userSockets.get('user-1')?.size).toBe(1); + expect(userSockets.get('user-1')?.has('sock-2')).toBe(true); + }); + + it('handles disconnect from unknown socket gracefully', () => { + const socket = createMockSocket(); + // No prior connection — should not throw + expect(() => gateway.handleDisconnect(socket)).not.toThrow(); + }); + }); + + describe('handleNotificationSent', () => { + const event: NotificationSentEvent = { + aggregateId: 'notif-1', + userId: 'user-1', + templateKey: 'listing_approved', + channel: 'EMAIL', + occurredAt: new Date('2026-04-16T12:00:00Z'), + } as any; + + it('emits notification:new to user room', async () => { + const roomEmit = vi.fn(); + mockServer.to.mockReturnValue({ emit: roomEmit }); + + await gateway.handleNotificationSent(event); + + expect(mockServer.to).toHaveBeenCalledWith('user:user-1'); + expect(roomEmit).toHaveBeenCalledWith('notification:new', { + id: 'notif-1', + templateKey: 'listing_approved', + channel: 'EMAIL', + occurredAt: '2026-04-16T12:00:00.000Z', + }); + }); + + it('emits updated unread count after notification', async () => { + const roomEmit = vi.fn(); + mockServer.to.mockReturnValue({ emit: roomEmit }); + + await gateway.handleNotificationSent(event); + + // Called twice: once for notification:new, once for unread-count + expect(roomEmit).toHaveBeenCalledWith('notification:unread-count', { unreadCount: 3 }); + }); + + it('increments cached unread count in Redis when key exists', async () => { + const mockIncr = vi.fn(); + mockRedisService.getClient.mockReturnValue({ + exists: vi.fn().mockResolvedValue(1), + incr: mockIncr, + }); + mockServer.to.mockReturnValue({ emit: vi.fn() }); + + await gateway.handleNotificationSent(event); + + expect(mockIncr).toHaveBeenCalled(); + }); + + it('does not throw when event handling fails', async () => { + mockServer.to.mockImplementation(() => { + throw new Error('server error'); + }); + + await expect(gateway.handleNotificationSent(event)).resolves.not.toThrow(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to emit'), + expect.any(String), + 'NotificationsGateway', + ); + }); + }); + + describe('emitUnreadCount', () => { + it('emits unread count to user room', async () => { + const roomEmit = vi.fn(); + mockServer.to.mockReturnValue({ emit: roomEmit }); + + await gateway.emitUnreadCount('user-1'); + + expect(mockServer.to).toHaveBeenCalledWith('user:user-1'); + expect(roomEmit).toHaveBeenCalledWith('notification:unread-count', { unreadCount: 3 }); + }); + }); + + describe('invalidateUnreadCount', () => { + it('deletes cached unread count from Redis', async () => { + await gateway.invalidateUnreadCount('user-1'); + + expect(mockRedisService.del).toHaveBeenCalledWith('notifications:unread:user-1'); + }); + + it('skips deletion when Redis unavailable', async () => { + mockRedisService.isAvailable.mockReturnValue(false); + + await gateway.invalidateUnreadCount('user-1'); + + expect(mockRedisService.del).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/modules/notifications/presentation/__tests__/zalo-oa-webhook.controller.spec.ts b/apps/api/src/modules/notifications/presentation/__tests__/zalo-oa-webhook.controller.spec.ts new file mode 100644 index 0000000..f3807f9 --- /dev/null +++ b/apps/api/src/modules/notifications/presentation/__tests__/zalo-oa-webhook.controller.spec.ts @@ -0,0 +1,225 @@ +import { ZaloOaWebhookController } from '../controllers/zalo-oa-webhook.controller'; + +describe('ZaloOaWebhookController', () => { + let controller: ZaloOaWebhookController; + let mockPrisma: { + oAuthAccount: { + findFirst: ReturnType; + }; + }; + let mockLogger: { + log: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + let mockZaloOaService: { isAvailable: boolean }; + + beforeEach(() => { + mockPrisma = { + oAuthAccount: { findFirst: vi.fn() }, + }; + mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + mockZaloOaService = { isAvailable: true }; + + controller = new ZaloOaWebhookController( + mockPrisma as any, + mockLogger as any, + mockZaloOaService as any, + ); + }); + + describe('verify', () => { + it('returns the challenge token', () => { + const result = controller.verify('test-challenge-123'); + expect(result).toBe('test-challenge-123'); + }); + + it('returns empty string when no challenge provided', () => { + const result = controller.verify(undefined as any); + expect(result).toBe(''); + }); + }); + + describe('handleEvent', () => { + const mockReq = {} as any; + + it('returns received:true for all events', async () => { + const result = await controller.handleEvent( + { app_id: 'app-1', event_name: 'follow', timestamp: '123', sender: { id: 'zalo-1' }, recipient: { id: 'oa-1' } }, + mockReq, + ); + expect(result).toEqual({ received: true }); + }); + + it('skips processing when Zalo OA not configured', async () => { + mockZaloOaService.isAvailable = false; + + await controller.handleEvent( + { app_id: 'app-1', event_name: 'follow', timestamp: '123', sender: { id: 'zalo-1' }, recipient: { id: 'oa-1' } }, + mockReq, + ); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('not configured'), + 'ZaloOaWebhookController', + ); + expect(mockPrisma.oAuthAccount.findFirst).not.toHaveBeenCalled(); + }); + + describe('follow event', () => { + it('checks for existing OAuth link on follow', async () => { + mockPrisma.oAuthAccount.findFirst.mockResolvedValue(null); + + await controller.handleEvent( + { app_id: 'app-1', event_name: 'follow', timestamp: '123', sender: { id: 'zalo-user-123' }, recipient: { id: 'oa-1' } }, + mockReq, + ); + + expect(mockPrisma.oAuthAccount.findFirst).toHaveBeenCalledWith({ + where: { provider: 'ZALO', providerUserId: 'zalo-user-123' }, + }); + }); + + it('logs when user is already linked', async () => { + mockPrisma.oAuthAccount.findFirst.mockResolvedValue({ + userId: 'user-abc', + providerUserId: 'zalo-user-123', + }); + + await controller.handleEvent( + { app_id: 'app-1', event_name: 'follow', timestamp: '123', sender: { id: 'zalo-user-123' }, recipient: { id: 'oa-1' } }, + mockReq, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('already linked'), + 'ZaloOaWebhookController', + ); + }); + + it('logs when no link found (manual linking needed)', async () => { + mockPrisma.oAuthAccount.findFirst.mockResolvedValue(null); + + await controller.handleEvent( + { app_id: 'app-1', event_name: 'follow', timestamp: '123', sender: { id: 'zalo-user-456' }, recipient: { id: 'oa-1' } }, + mockReq, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('no existing link'), + 'ZaloOaWebhookController', + ); + }); + }); + + describe('unfollow event', () => { + it('logs unfollow event', async () => { + await controller.handleEvent( + { app_id: 'app-1', event_name: 'unfollow', timestamp: '123', sender: { id: 'zalo-user-789' }, recipient: { id: 'oa-1' } }, + mockReq, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('unfollowed'), + 'ZaloOaWebhookController', + ); + }); + }); + + describe('user_send_text event', () => { + it('logs incoming message and checks for linked user', async () => { + mockPrisma.oAuthAccount.findFirst.mockResolvedValue({ userId: 'user-linked' }); + + await controller.handleEvent( + { + app_id: 'app-1', + event_name: 'user_send_text', + timestamp: '123', + sender: { id: 'zalo-user-100' }, + recipient: { id: 'oa-1' }, + message: { text: 'Xin chào', msg_id: 'msg-001' }, + }, + mockReq, + ); + + expect(mockPrisma.oAuthAccount.findFirst).toHaveBeenCalledWith({ + where: { provider: 'ZALO', providerUserId: 'zalo-user-100' }, + select: { userId: true }, + }); + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('linked user user-linked'), + 'ZaloOaWebhookController', + ); + }); + + it('handles message from unlinked user', async () => { + mockPrisma.oAuthAccount.findFirst.mockResolvedValue(null); + + await controller.handleEvent( + { + app_id: 'app-1', + event_name: 'user_send_text', + timestamp: '123', + sender: { id: 'zalo-user-200' }, + recipient: { id: 'oa-1' }, + message: { text: 'Hello', msg_id: 'msg-002' }, + }, + mockReq, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Message from Zalo UID'), + 'ZaloOaWebhookController', + ); + }); + + it('ignores messages without text', async () => { + await controller.handleEvent( + { + app_id: 'app-1', + event_name: 'user_send_text', + timestamp: '123', + sender: { id: 'zalo-user-300' }, + recipient: { id: 'oa-1' }, + message: { msg_id: 'msg-003' }, + }, + mockReq, + ); + + expect(mockPrisma.oAuthAccount.findFirst).not.toHaveBeenCalled(); + }); + }); + + describe('unknown events', () => { + it('logs unhandled event types', async () => { + await controller.handleEvent( + { app_id: 'app-1', event_name: 'user_send_image', timestamp: '123', sender: { id: 'zalo-1' }, recipient: { id: 'oa-1' } }, + mockReq, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Unhandled event type'), + 'ZaloOaWebhookController', + ); + }); + }); + + describe('error handling', () => { + it('catches and logs errors without throwing', async () => { + mockPrisma.oAuthAccount.findFirst.mockRejectedValue(new Error('DB connection lost')); + + const result = await controller.handleEvent( + { app_id: 'app-1', event_name: 'follow', timestamp: '123', sender: { id: 'zalo-1' }, recipient: { id: 'oa-1' } }, + mockReq, + ); + + expect(result).toEqual({ received: true }); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('DB connection lost'), + expect.any(String), + 'ZaloOaWebhookController', + ); + }); + }); + }); +}); diff --git a/apps/api/src/modules/notifications/presentation/controllers/zalo-oa-webhook.controller.ts b/apps/api/src/modules/notifications/presentation/controllers/zalo-oa-webhook.controller.ts new file mode 100644 index 0000000..4279055 --- /dev/null +++ b/apps/api/src/modules/notifications/presentation/controllers/zalo-oa-webhook.controller.ts @@ -0,0 +1,166 @@ +import { Body, Controller, Get, HttpCode, Post, Query, RawBodyRequest, Req } from '@nestjs/common'; +import type { Request } from 'express'; +import { LoggerService, PrismaService } from '@modules/shared'; +import { ZaloOaService } from '../../infrastructure/services/zalo-oa.service'; + +/** + * Zalo OA event types from webhook payloads. + * + * @see https://developers.zalo.me/docs/official-account/webhook + */ +interface ZaloOaWebhookPayload { + app_id: string; + event_name: string; + timestamp: string; + sender: { id: string }; + recipient: { id: string }; + message?: { text?: string; msg_id?: string; attachments?: unknown[] }; + follower?: { id: string }; + user_id_by_app?: string; +} + +const WEBHOOK_CONTEXT = 'ZaloOaWebhookController'; + +@Controller('webhooks/zalo-oa') +export class ZaloOaWebhookController { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + private readonly zaloOaService: ZaloOaService, + ) {} + + /** + * Webhook verification endpoint. + * Zalo OA sends a GET request with a challenge token during webhook setup. + */ + @Get() + verify(@Query('challenge') challenge: string): string { + this.logger.log(`Webhook verification: challenge=${challenge}`, WEBHOOK_CONTEXT); + return challenge ?? ''; + } + + /** + * Receive and process Zalo OA webhook events. + * + * Supported events: + * - `follow` — user follows the OA, attempt to link via phone + * - `unfollow` — user unfollows the OA + * - `user_send_text` — user sends a text message to the OA + */ + @Post() + @HttpCode(200) + async handleEvent( + @Body() payload: ZaloOaWebhookPayload, + @Req() req: RawBodyRequest, + ): Promise<{ received: true }> { + const { event_name, sender, timestamp } = payload; + + this.logger.log( + `Webhook event: ${event_name} from=${sender?.id ?? 'unknown'} at=${timestamp}`, + WEBHOOK_CONTEXT, + ); + + // Verify OA secret (app_id must match our configured OA) + if (!this.zaloOaService.isAvailable) { + this.logger.warn('Zalo OA not configured — ignoring webhook event', WEBHOOK_CONTEXT); + return { received: true }; + } + + try { + switch (event_name) { + case 'follow': + await this.handleFollow(payload); + break; + case 'unfollow': + await this.handleUnfollow(payload); + break; + case 'user_send_text': + await this.handleUserMessage(payload); + break; + default: + this.logger.log(`Unhandled event type: ${event_name}`, WEBHOOK_CONTEXT); + } + } catch (error) { + this.logger.error( + `Webhook processing failed for ${event_name}: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + WEBHOOK_CONTEXT, + ); + } + + return { received: true }; + } + + /** + * Handle `follow` event — attempt to link the Zalo user to a platform user. + * + * Linking strategy: look up OAuthAccount with provider=ZALO and matching providerUserId, + * or try phone-based matching if the Zalo user ID can be resolved to a phone. + */ + private async handleFollow(payload: ZaloOaWebhookPayload): Promise { + const zaloUid = payload.sender?.id ?? payload.follower?.id; + if (!zaloUid) return; + + // Check if already linked via OAuth + const existingLink = await this.prisma.oAuthAccount.findFirst({ + where: { provider: 'ZALO', providerUserId: zaloUid }, + }); + + if (existingLink) { + this.logger.log( + `Follow event: Zalo UID ${zaloUid.slice(0, 6)}*** already linked to user ${existingLink.userId}`, + WEBHOOK_CONTEXT, + ); + return; + } + + this.logger.log( + `Follow event: Zalo UID ${zaloUid.slice(0, 6)}*** — no existing link found. Manual linking may be required via phone verification.`, + WEBHOOK_CONTEXT, + ); + } + + /** + * Handle `unfollow` event — log the event for analytics. + * We do NOT remove the OAuth link (user may re-follow). + */ + private async handleUnfollow(payload: ZaloOaWebhookPayload): Promise { + const zaloUid = payload.sender?.id; + if (!zaloUid) return; + + this.logger.log( + `Unfollow event: Zalo UID ${zaloUid.slice(0, 6)}*** unfollowed OA`, + WEBHOOK_CONTEXT, + ); + } + + /** + * Handle incoming text message from a Zalo user. + * Logs the message for now — can be extended to create inquiries or route to messaging. + */ + private async handleUserMessage(payload: ZaloOaWebhookPayload): Promise { + const zaloUid = payload.sender?.id; + const text = payload.message?.text; + const msgId = payload.message?.msg_id; + + if (!zaloUid || !text) return; + + this.logger.log( + `Message from Zalo UID ${zaloUid.slice(0, 6)}***: msgId=${msgId ?? 'unknown'} length=${text.length}`, + WEBHOOK_CONTEXT, + ); + + // Find linked user if any + const link = await this.prisma.oAuthAccount.findFirst({ + where: { provider: 'ZALO', providerUserId: zaloUid }, + select: { userId: true }, + }); + + if (link) { + this.logger.log( + `Message from linked user ${link.userId} via Zalo OA`, + WEBHOOK_CONTEXT, + ); + } + } +}