Files
goodgo-platform/apps/api/src/modules/notifications/presentation/controllers/zalo-oa-link.controller.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

120 lines
3.9 KiB
TypeScript

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