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; records interaction + checks existing link * - `unfollow` — user unfollows the OA * - `user_send_text` — user sends a text message; records interaction */ @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, ); // Accept webhooks regardless of which mode is active if (!this.zaloOaService.isAvailable && !this.zaloOaService.isOAuthEnabled) { 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 — record interaction (opens 24-hour ZNS window) * and log link status. */ private async handleFollow(payload: ZaloOaWebhookPayload): Promise { const zaloUid = payload.sender?.id ?? payload.follower?.id; if (!zaloUid) return; // Record interaction so the 24-hour window opens for ZNS sends await this.zaloOaService.recordInteraction(zaloUid); // Check OA account-links table first const oaLink = await this.prisma.zaloAccountLink.findFirst({ where: { zaloUserId: zaloUid }, }); if (oaLink) { this.logger.log( `Follow event: Zalo UID ${zaloUid.slice(0, 6)}*** already OA-linked to user ${oaLink.userId}`, WEBHOOK_CONTEXT, ); return; } // Legacy: check OAuthAccount const existingOAuth = await this.prisma.oAuthAccount.findFirst({ where: { provider: 'ZALO', providerUserId: zaloUid }, }); if (existingOAuth) { this.logger.log( `Follow event: Zalo UID ${zaloUid.slice(0, 6)}*** linked via social OAuth to user ${existingOAuth.userId}`, WEBHOOK_CONTEXT, ); return; } this.logger.log( `Follow event: Zalo UID ${zaloUid.slice(0, 6)}*** — no existing link found. User should complete OA linking via /auth/zalo-oa/link.`, WEBHOOK_CONTEXT, ); } /** * Handle `unfollow` event — log for analytics. * We do NOT remove the OA link (user may re-follow and still want notifications). */ 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. * Records the interaction (refreshes the 24-hour ZNS window) and logs for routing. */ 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; // Record interaction so the ZNS send window stays open await this.zaloOaService.recordInteraction(zaloUid); this.logger.log( `Message from Zalo UID ${zaloUid.slice(0, 6)}***: msgId=${msgId ?? 'unknown'} length=${text.length}`, WEBHOOK_CONTEXT, ); // Find linked user via OA account-links const oaLink = await this.prisma.zaloAccountLink.findFirst({ where: { zaloUserId: zaloUid }, select: { userId: true }, }); if (oaLink) { this.logger.log( `Message from OA-linked user ${oaLink.userId} via Zalo OA`, WEBHOOK_CONTEXT, ); } } }