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:
@@ -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