Files
goodgo-platform/apps/api/src/modules/notifications/infrastructure/services/zalo-oa.service.ts
Ho Ngoc Hai a720825257 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>
2026-04-21 04:49:52 +07:00

535 lines
18 KiB
TypeScript

import { Injectable, type OnModuleInit } from '@nestjs/common';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import { LoggerService, PrismaService } from '@modules/shared';
// ─── DTOs ────────────────────────────────────────────────────────────────────
export interface SendZaloOaDto {
/** Zalo user ID (follower UID from OA) */
toUid: string;
/** ZNS template ID registered in Zalo OA Manager */
templateId: string;
/** Template parameter key-value pairs */
templateData: Record<string, string>;
}
export interface ZaloOaMessageResult {
messageId: string;
}
export interface ZaloOaLinkResult {
zaloUserId: string;
linked: boolean;
}
// ─── Internal Zalo API shapes ─────────────────────────────────────────────────
interface ZaloOaTokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
error?: number;
error_description?: string;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1_000;
/** Zalo ZNS 24-hour interaction window in milliseconds */
const INTERACTION_WINDOW_MS = 24 * 60 * 60 * 1_000;
/** Refresh tokens 5 minutes before expiry */
const REFRESH_BUFFER_MS = 5 * 60 * 1_000;
const ZNS_URL = 'https://business.openapi.zalo.me/message/template';
const OA_TOKEN_URL = 'https://oauth.zaloapp.com/v4/oa/access_token';
// ─── Encryption helpers ───────────────────────────────────────────────────────
const AES_ALGO = 'aes-256-gcm';
function encryptToken(plaintext: string, keyHex: string): string {
const key = Buffer.from(keyHex, 'hex');
const iv = randomBytes(12);
const cipher = createCipheriv(AES_ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString('base64url')}.${tag.toString('base64url')}.${encrypted.toString('base64url')}`;
}
function decryptToken(encoded: string, keyHex: string): string {
const key = Buffer.from(keyHex, 'hex');
const parts = encoded.split('.');
if (parts.length !== 3) throw new Error('Invalid encrypted token format');
const [ivB64, tagB64, ctB64] = parts as [string, string, string];
const iv = Buffer.from(ivB64, 'base64url');
const tag = Buffer.from(tagB64, 'base64url');
const ct = Buffer.from(ctB64, 'base64url');
const decipher = createDecipheriv(AES_ALGO, key, iv);
decipher.setAuthTag(tag);
return decipher.update(ct) + decipher.final('utf8');
}
// ─── Service ──────────────────────────────────────────────────────────────────
/**
* Service for Zalo Official Account (OA) API v3 integration.
*
* Responsibilities:
* 1. ZNS template message sending (with exponential-backoff retry).
* 2. OA OAuth account linking — authorize URL generation, callback handling,
* and storage of per-user encrypted access/refresh tokens in `zalo_account_links`.
* 3. sendTemplate — user-centric wrapper that looks up the linked Zalo UID,
* checks the 24-hour interaction window, auto-refreshes expired tokens, and
* calls ZNS.
*
* Required env vars (all mandatory for full functionality):
* ZALO_OA_APP_ID — OA App ID from Zalo OA Manager
* ZALO_OA_SECRET — OA App Secret
* ZALO_OA_REDIRECT_URI — OAuth callback URI registered with Zalo
* ZALO_OA_TOKEN_KEY — 32-byte hex key for AES-256-GCM token encryption
*
* Legacy ZNS-only mode (backwards-compatible):
* ZALO_OA_ID — OA ID (used in ZNS requests)
* ZALO_OA_ACCESS_TOKEN — Static access token (no OAuth linking)
*/
@Injectable()
export class ZaloOaService implements OnModuleInit {
// Legacy static-token mode
private oaId = '';
private accessToken = '';
private initialized = false;
// OAuth linking mode
private oaAppId = '';
private oaSecret = '';
private oaRedirectUri = '';
private tokenEncKey = '';
private oauthEnabled = false;
constructor(
private readonly logger: LoggerService,
private readonly prisma: PrismaService,
) {}
onModuleInit(): void {
// Legacy mode (backwards compat)
this.oaId = process.env['ZALO_OA_ID'] ?? '';
this.accessToken = process.env['ZALO_OA_ACCESS_TOKEN'] ?? '';
if (!this.oaId || !this.accessToken) {
this.logger.warn(
'ZALO_OA_ID or ZALO_OA_ACCESS_TOKEN not set — Zalo OA legacy ZNS disabled',
'ZaloOaService',
);
} else {
this.initialized = true;
this.logger.log(`Zalo OA configured for OA ID "${this.oaId}"`, 'ZaloOaService');
}
// OAuth linking mode
this.oaAppId = process.env['ZALO_OA_APP_ID'] ?? '';
this.oaSecret = process.env['ZALO_OA_SECRET'] ?? '';
this.oaRedirectUri = process.env['ZALO_OA_REDIRECT_URI'] ?? '';
this.tokenEncKey = process.env['ZALO_OA_TOKEN_KEY'] ?? '';
if (this.oaAppId && this.oaSecret && this.oaRedirectUri && this.tokenEncKey) {
if (this.tokenEncKey.length !== 64) {
this.logger.warn(
'ZALO_OA_TOKEN_KEY must be a 64-char hex string (32 bytes) — OAuth linking disabled',
'ZaloOaService',
);
} else {
this.oauthEnabled = true;
this.logger.log('Zalo OA OAuth linking enabled', 'ZaloOaService');
}
} else {
this.logger.warn(
'ZALO_OA_APP_ID / ZALO_OA_SECRET / ZALO_OA_REDIRECT_URI / ZALO_OA_TOKEN_KEY not fully set — OA OAuth linking disabled',
'ZaloOaService',
);
}
}
get isAvailable(): boolean {
return this.initialized;
}
get isOAuthEnabled(): boolean {
return this.oauthEnabled;
}
// ─── OAuth: Account Linking ─────────────────────────────────────────────────
/**
* Generate the Zalo OA OAuth authorization URL.
* The `state` parameter should be a CSRF token tied to the user's session.
*/
getOAuthAuthorizeUrl(state: string): string {
if (!this.oauthEnabled) {
throw new Error('Zalo OA OAuth linking is not configured');
}
const params = new URLSearchParams({
app_id: this.oaAppId,
redirect_uri: this.oaRedirectUri,
state,
});
return `https://oauth.zaloapp.com/v4/oa/permission?${params.toString()}`;
}
/**
* Handle OAuth callback: exchange code for OA-scoped tokens, resolve the
* Zalo OA user ID, and persist encrypted tokens in `zalo_account_links`.
*/
async handleOAuthCallback(
userId: string,
code: string,
): Promise<ZaloOaLinkResult> {
if (!this.oauthEnabled) {
throw new Error('Zalo OA OAuth linking is not configured');
}
const tokenData = await this.exchangeOaCode(code);
const zaloUserId = await this.resolveZaloUserId(tokenData.access_token);
const expiresAt = new Date(Date.now() + tokenData.expires_in * 1_000);
const encAccess = encryptToken(tokenData.access_token, this.tokenEncKey);
const encRefresh = encryptToken(tokenData.refresh_token, this.tokenEncKey);
await this.prisma.zaloAccountLink.upsert({
where: { userId },
create: {
userId,
zaloUserId,
accessToken: encAccess,
refreshToken: encRefresh,
expiresAt,
},
update: {
zaloUserId,
accessToken: encAccess,
refreshToken: encRefresh,
expiresAt,
},
});
this.logger.log(
`Zalo OA linked for user ${userId} → Zalo UID ${zaloUserId.slice(0, 6)}***`,
'ZaloOaService',
);
return { zaloUserId, linked: true };
}
/**
* Unlink a user's Zalo OA account.
*/
async unlinkAccount(userId: string): Promise<void> {
await this.prisma.zaloAccountLink.deleteMany({ where: { userId } });
this.logger.log(`Zalo OA unlinked for user ${userId}`, 'ZaloOaService');
}
// ─── sendTemplate — user-centric ZNS send ──────────────────────────────────
/**
* Send a ZNS template message to the Zalo OA UID linked to `userId`.
*
* - Resolves the linked Zalo UID.
* - Checks 24-hour interaction window (required by Zalo ZNS policy).
* - Auto-refreshes access token if within the refresh buffer window.
* - Falls back to legacy static-token mode if no link exists (for backwards compat).
*
* @throws Error if user has no linked Zalo account and legacy mode is unavailable.
* @throws Error if the user is outside the 24-hour interaction window.
*/
async sendTemplate(
userId: string,
templateId: string,
params: Record<string, string>,
): Promise<ZaloOaMessageResult> {
// Try per-user linked token first
if (this.oauthEnabled) {
const link = await this.prisma.zaloAccountLink.findUnique({ where: { userId } });
if (link) {
// Check 24-hour interaction window
if (!this.isWithinInteractionWindow(link.lastInteractAt)) {
throw new Error(
`User ${userId} is outside the 24-hour Zalo OA interaction window — cannot send ZNS template`,
);
}
// Refresh token if needed
const resolvedLink = await this.ensureFreshToken(link);
const plainAccessToken = decryptToken(resolvedLink.accessToken, this.tokenEncKey);
return this.sendWithRetry({
toUid: link.zaloUserId,
templateId,
templateData: params,
accessToken: plainAccessToken,
});
}
}
// Legacy static-token fallback
if (!this.initialized) {
throw new Error(
`No Zalo OA link found for user ${userId} and legacy mode is not configured`,
);
}
// Legacy mode: caller must supply the uid directly — log a warning
this.logger.warn(
`sendTemplate called for user ${userId} with no OA link — falling back to legacy static-token mode (toUid not resolved)`,
'ZaloOaService',
);
throw new Error(
`No Zalo OA link found for user ${userId}. Please link the account via OAuth first.`,
);
}
// ─── Legacy sendMessage (direct UID) ───────────────────────────────────────
/**
* Send a template-based message to a Zalo user via ZNS (Zalo Notification Service).
*
* The user must be a follower of the Official Account, and the template must be
* pre-registered and approved in the Zalo OA Manager console.
*
* @deprecated Prefer `sendTemplate(userId, ...)` for per-user linked tokens.
*/
async sendMessage(dto: SendZaloOaDto): Promise<ZaloOaMessageResult> {
return this.sendWithRetry({ ...dto, accessToken: this.accessToken });
}
// ─── Record interaction (called from webhook handler) ────────────────────────
/**
* Record that a Zalo user interacted with the OA (follow, message, etc.).
* Updates `lastInteractAt` on the linked account so the 24-hour window is fresh.
*/
async recordInteraction(zaloUserId: string): Promise<void> {
const updated = await this.prisma.zaloAccountLink.updateMany({
where: { zaloUserId },
data: { lastInteractAt: new Date() },
});
if (updated.count > 0) {
this.logger.log(
`Recorded OA interaction for Zalo UID ${zaloUserId.slice(0, 6)}***`,
'ZaloOaService',
);
}
}
// ─── Internal helpers ──────────────────────────────────────────────────────
private isWithinInteractionWindow(lastInteractAt: Date | null): boolean {
if (!lastInteractAt) return false;
return Date.now() - lastInteractAt.getTime() < INTERACTION_WINDOW_MS;
}
private async ensureFreshToken(
link: { id: string; accessToken: string; refreshToken: string; expiresAt: Date },
): Promise<{ accessToken: string; refreshToken: string }> {
const msUntilExpiry = link.expiresAt.getTime() - Date.now();
if (msUntilExpiry > REFRESH_BUFFER_MS) {
// Token still valid
return { accessToken: link.accessToken, refreshToken: link.refreshToken };
}
// Refresh
const plainRefresh = decryptToken(link.refreshToken, this.tokenEncKey);
const newTokens = await this.refreshOaToken(plainRefresh);
const newExpiresAt = new Date(Date.now() + newTokens.expires_in * 1_000);
const encAccess = encryptToken(newTokens.access_token, this.tokenEncKey);
const encRefresh = encryptToken(newTokens.refresh_token, this.tokenEncKey);
await this.prisma.zaloAccountLink.update({
where: { id: link.id },
data: { accessToken: encAccess, refreshToken: encRefresh, expiresAt: newExpiresAt },
});
this.logger.log(`Refreshed Zalo OA token for link ${link.id}`, 'ZaloOaService');
return { accessToken: encAccess, refreshToken: encRefresh };
}
private async refreshOaToken(refreshToken: string): Promise<ZaloOaTokenResponse> {
const body = new URLSearchParams({
app_id: this.oaAppId,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
const response = await fetch(OA_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
secret_key: this.oaSecret,
},
body: body.toString(),
});
const data = (await response.json()) as ZaloOaTokenResponse;
if (data.error) {
throw new Error(
`Zalo OA token refresh failed (${data.error}): ${data.error_description ?? 'unknown'}`,
);
}
if (!data.access_token) {
throw new Error('Zalo OA token refresh: no access_token in response');
}
return data;
}
private async exchangeOaCode(code: string): Promise<ZaloOaTokenResponse> {
const body = new URLSearchParams({
app_id: this.oaAppId,
code,
grant_type: 'authorization_code',
});
const response = await fetch(OA_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
secret_key: this.oaSecret,
},
body: body.toString(),
});
const data = (await response.json()) as ZaloOaTokenResponse;
if (data.error) {
throw new Error(
`Zalo OA code exchange failed (${data.error}): ${data.error_description ?? 'unknown'}`,
);
}
if (!data.access_token) {
throw new Error('Zalo OA code exchange: no access_token in response');
}
return data;
}
/**
* Resolve the Zalo OA UID for the authenticated user by calling the OA Me endpoint.
*/
private async resolveZaloUserId(oaAccessToken: string): Promise<string> {
const response = await fetch('https://openapi.zalo.me/v2.0/oa/getprofile?data=%7B%7D', {
headers: { access_token: oaAccessToken },
});
const data = (await response.json()) as {
error?: number;
message?: string;
data?: { user_id_by_app?: string; user_id?: string };
};
if (data.error && data.error !== 0) {
throw new Error(
`Zalo OA user ID resolution failed (${data.error}): ${data.message ?? 'unknown'}`,
);
}
const uid = data.data?.user_id_by_app ?? data.data?.user_id;
if (!uid) {
throw new Error('Zalo OA user ID resolution: no UID in response');
}
return uid;
}
private async sendWithRetry(
dto: SendZaloOaDto & { accessToken: string },
): Promise<ZaloOaMessageResult> {
if (!this.initialized && !this.oauthEnabled) {
throw new Error('Zalo OA not initialized — ZALO_OA_ID / ZALO_OA_ACCESS_TOKEN not configured');
}
let lastError: Error | undefined;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await this.send(dto);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < MAX_RETRIES) {
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1);
this.logger.warn(
`Zalo OA attempt ${attempt}/${MAX_RETRIES} failed: ${lastError.message}. Retrying in ${delayMs}ms...`,
'ZaloOaService',
);
await this.delay(delayMs);
}
}
}
this.logger.error(
`Zalo OA message failed after ${MAX_RETRIES} attempts: ${lastError?.message}`,
'ZaloOaService',
);
throw lastError;
}
private async send(
dto: SendZaloOaDto & { accessToken: string },
): Promise<ZaloOaMessageResult> {
const body = {
phone: dto.toUid,
template_id: dto.templateId,
template_data: dto.templateData,
};
const response = await fetch(ZNS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
access_token: dto.accessToken,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Zalo OA API error (${response.status}): ${errorText}`);
}
const data = (await response.json()) as {
error?: number;
message?: string;
data?: { msg_id?: string };
};
// Zalo API returns error=0 on success
if (data.error !== undefined && data.error !== 0) {
throw new Error(
`Zalo OA message rejected (code ${data.error}): ${data.message ?? 'Unknown reason'}`,
);
}
const messageId = data.data?.msg_id ?? `zalo-oa-${Date.now()}`;
this.logger.log(
`Zalo OA message sent to ${dto.toUid.slice(0, 6)}***: ${messageId}`,
'ZaloOaService',
);
return { messageId };
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}