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:
Ho Ngoc Hai
2026-04-21 04:49:52 +07:00
parent 603ef7db86
commit a720825257
8 changed files with 1198 additions and 248 deletions

View File

@@ -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,
);
}