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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
handshake: { auth?: Record<string, unknown>; headers?: Record<string, unknown>; query?: Record<string, unknown> };
|
||||
join: ReturnType<typeof vi.fn>;
|
||||
emit: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
}> = {}) {
|
||||
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<typeof vi.fn> };
|
||||
let mockLogger: {
|
||||
log: ReturnType<typeof vi.fn>;
|
||||
debug: ReturnType<typeof vi.fn>;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockRedisService: {
|
||||
isAvailable: ReturnType<typeof vi.fn>;
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
set: ReturnType<typeof vi.fn>;
|
||||
del: ReturnType<typeof vi.fn>;
|
||||
getClient: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockNotificationRepo: { countUnreadByUserId: ReturnType<typeof vi.fn> };
|
||||
let mockServer: {
|
||||
to: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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<string, Set<string>>;
|
||||
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<string, Set<string>>;
|
||||
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<string, Set<string>>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { ZaloOaWebhookController } from '../controllers/zalo-oa-webhook.controller';
|
||||
|
||||
describe('ZaloOaWebhookController', () => {
|
||||
let controller: ZaloOaWebhookController;
|
||||
let mockPrisma: {
|
||||
oAuthAccount: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
let mockLogger: {
|
||||
log: ReturnType<typeof vi.fn>;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Request>,
|
||||
): 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user