feat(notifications): add ZaloOaLinkController + migration + schema — TEC-3065
Include files missed from previous commit: - ZaloOaLinkController (GET /auth/zalo-oa/link, GET /auth/zalo-oa/callback, DELETE) - prisma/schema.prisma — ZaloAccountLink model + User.zaloAccountLink relation - prisma/migrations/20260421010000_add_zalo_account_links/migration.sql - Updated ZaloOaService, webhook controller, notifications module, and specs Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { type Response } from 'express';
|
||||
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator';
|
||||
import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service';
|
||||
import { ZaloOaService } from '../../infrastructure/services/zalo-oa.service';
|
||||
|
||||
const FRONTEND_URL = process.env['FRONTEND_URL'] ?? 'http://localhost:3000';
|
||||
const CSRF_STATE_LENGTH = 32;
|
||||
|
||||
function generateCsrfState(): string {
|
||||
return Buffer.from(
|
||||
Array.from({ length: CSRF_STATE_LENGTH }, () => Math.floor(Math.random() * 256)),
|
||||
).toString('base64url');
|
||||
}
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth/zalo-oa')
|
||||
export class ZaloOaLinkController {
|
||||
constructor(private readonly zaloOaService: ZaloOaService) {}
|
||||
|
||||
/**
|
||||
* Initiate Zalo OA account linking for the authenticated user.
|
||||
*
|
||||
* Returns 302 redirect to the Zalo OA consent screen.
|
||||
* On return, Zalo calls back to `/auth/zalo-oa/callback`.
|
||||
*
|
||||
* The `state` param encodes `userId:csrfToken` so the callback can verify
|
||||
* the request origin without a server-side session.
|
||||
*/
|
||||
@Get('link')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Initiate Zalo OA account linking' })
|
||||
@ApiResponse({ status: 302, description: 'Redirect to Zalo OA consent screen' })
|
||||
initiateLink(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Res() res: Response,
|
||||
): void {
|
||||
if (!this.zaloOaService.isOAuthEnabled) {
|
||||
throw new BadRequestException('Zalo OA linking is not configured on this server');
|
||||
}
|
||||
|
||||
const csrf = generateCsrfState();
|
||||
// Encode userId + csrf into state so the callback can verify
|
||||
const state = Buffer.from(JSON.stringify({ uid: user.sub, csrf })).toString('base64url');
|
||||
|
||||
const authUrl = this.zaloOaService.getOAuthAuthorizeUrl(state);
|
||||
res.redirect(authUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zalo OA OAuth callback.
|
||||
*
|
||||
* Exchanges the authorization code for OA-scoped tokens, resolves the Zalo OA UID,
|
||||
* and stores encrypted tokens in `zalo_account_links`.
|
||||
*
|
||||
* On success redirects to frontend `/settings/zalo?linked=true`.
|
||||
* On failure redirects to frontend `/settings/zalo?error=<reason>`.
|
||||
*/
|
||||
@Throttle({ default: { ttl: 3_600_000, limit: 10 } })
|
||||
@Get('callback')
|
||||
@ApiOperation({ summary: 'Zalo OA OAuth2 callback' })
|
||||
@ApiResponse({ status: 302, description: 'Redirect to frontend settings page' })
|
||||
async handleCallback(
|
||||
@Query('code') code: string,
|
||||
@Query('state') state: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
if (!code || !state) {
|
||||
res.redirect(`${FRONTEND_URL}/settings/zalo?error=missing_params`);
|
||||
return;
|
||||
}
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')) as {
|
||||
uid?: string;
|
||||
};
|
||||
if (!decoded.uid) throw new Error('missing uid in state');
|
||||
userId = decoded.uid;
|
||||
} catch {
|
||||
res.redirect(`${FRONTEND_URL}/settings/zalo?error=invalid_state`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.zaloOaService.handleOAuthCallback(userId, code);
|
||||
res.redirect(`${FRONTEND_URL}/settings/zalo?linked=true`);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'unknown';
|
||||
res.redirect(
|
||||
`${FRONTEND_URL}/settings/zalo?error=link_failed&detail=${encodeURIComponent(msg)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink the authenticated user's Zalo OA account.
|
||||
*/
|
||||
@Delete('link')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Unlink Zalo OA account' })
|
||||
@ApiResponse({ status: 204, description: 'Account unlinked' })
|
||||
async unlink(@CurrentUser() user: JwtPayload): Promise<void> {
|
||||
await this.zaloOaService.unlinkAccount(user.sub);
|
||||
}
|
||||
}
|
||||
@@ -43,9 +43,9 @@ export class ZaloOaWebhookController {
|
||||
* Receive and process Zalo OA webhook events.
|
||||
*
|
||||
* Supported events:
|
||||
* - `follow` — user follows the OA, attempt to link via phone
|
||||
* - `follow` — user follows the OA; records interaction + checks existing link
|
||||
* - `unfollow` — user unfollows the OA
|
||||
* - `user_send_text` — user sends a text message to the OA
|
||||
* - `user_send_text` — user sends a text message; records interaction
|
||||
*/
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@@ -60,8 +60,8 @@ export class ZaloOaWebhookController {
|
||||
WEBHOOK_CONTEXT,
|
||||
);
|
||||
|
||||
// Verify OA secret (app_id must match our configured OA)
|
||||
if (!this.zaloOaService.isAvailable) {
|
||||
// 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 };
|
||||
}
|
||||
@@ -92,37 +92,51 @@ export class ZaloOaWebhookController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Handle `follow` event — record interaction (opens 24-hour ZNS window)
|
||||
* and log link status.
|
||||
*/
|
||||
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({
|
||||
// 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 (existingLink) {
|
||||
if (existingOAuth) {
|
||||
this.logger.log(
|
||||
`Follow event: Zalo UID ${zaloUid.slice(0, 6)}*** already linked to user ${existingLink.userId}`,
|
||||
`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. Manual linking may be required via phone verification.`,
|
||||
`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 the event for analytics.
|
||||
* We do NOT remove the OAuth link (user may re-follow).
|
||||
* 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<void> {
|
||||
const zaloUid = payload.sender?.id;
|
||||
@@ -136,7 +150,7 @@ export class ZaloOaWebhookController {
|
||||
|
||||
/**
|
||||
* Handle incoming text message from a Zalo user.
|
||||
* Logs the message for now — can be extended to create inquiries or route to messaging.
|
||||
* Records the interaction (refreshes the 24-hour ZNS window) and logs for routing.
|
||||
*/
|
||||
private async handleUserMessage(payload: ZaloOaWebhookPayload): Promise<void> {
|
||||
const zaloUid = payload.sender?.id;
|
||||
@@ -145,20 +159,23 @@ export class ZaloOaWebhookController {
|
||||
|
||||
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 if any
|
||||
const link = await this.prisma.oAuthAccount.findFirst({
|
||||
where: { provider: 'ZALO', providerUserId: zaloUid },
|
||||
// Find linked user via OA account-links
|
||||
const oaLink = await this.prisma.zaloAccountLink.findFirst({
|
||||
where: { zaloUserId: zaloUid },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (link) {
|
||||
if (oaLink) {
|
||||
this.logger.log(
|
||||
`Message from linked user ${link.userId} via Zalo OA`,
|
||||
`Message from OA-linked user ${oaLink.userId} via Zalo OA`,
|
||||
WEBHOOK_CONTEXT,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user