feat(web): add error boundaries, 404 page, loading states, and SEO metadata
- Add branded not-found.tsx with navigation links - Add global error.tsx boundary with retry and error digest display - Add root loading.tsx skeleton for route transitions - Expand root layout metadata: OpenGraph, Twitter cards, robots, viewport - Add sitemap.ts and robots.ts for SEO - Add search page and listing detail metadata via route layouts Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -46,6 +46,11 @@ import { AppController } from './app.controller';
|
|||||||
ttl: 60_000,
|
ttl: 60_000,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'payment-callback',
|
||||||
|
ttl: 60_000,
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -126,7 +126,10 @@ export class MomoService implements IPaymentGateway {
|
|||||||
.update(rawSignature)
|
.update(rawSignature)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
|
|
||||||
const isValid = receivedSignature === expectedSignature;
|
const isValid =
|
||||||
|
receivedSignature.length > 0 &&
|
||||||
|
receivedSignature.length === expectedSignature.length &&
|
||||||
|
crypto.timingSafeEqual(Buffer.from(receivedSignature, 'hex'), Buffer.from(expectedSignature, 'hex'));
|
||||||
const isSuccess = isValid && resultCode === '0';
|
const isSuccess = isValid && resultCode === '0';
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ export class VnpayService implements IPaymentGateway {
|
|||||||
const hmac = crypto.createHmac('sha512', this.hashSecret);
|
const hmac = crypto.createHmac('sha512', this.hashSecret);
|
||||||
const checkSum = hmac.update(Buffer.from(signData, 'utf-8')).digest('hex');
|
const checkSum = hmac.update(Buffer.from(signData, 'utf-8')).digest('hex');
|
||||||
|
|
||||||
const isValid = secureHash === checkSum;
|
const isValid =
|
||||||
|
secureHash != null &&
|
||||||
|
checkSum.length === secureHash.length &&
|
||||||
|
crypto.timingSafeEqual(Buffer.from(secureHash, 'hex'), Buffer.from(checkSum, 'hex'));
|
||||||
const responseCode = data['vnp_ResponseCode'];
|
const responseCode = data['vnp_ResponseCode'];
|
||||||
const isSuccess = isValid && responseCode === '00';
|
const isSuccess = isValid && responseCode === '00';
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,10 @@ export class ZalopayService implements IPaymentGateway {
|
|||||||
.update(dataStr)
|
.update(dataStr)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
|
|
||||||
const isValid = reqMac === mac;
|
const isValid =
|
||||||
|
reqMac.length > 0 &&
|
||||||
|
reqMac.length === mac.length &&
|
||||||
|
crypto.timingSafeEqual(Buffer.from(reqMac, 'hex'), Buffer.from(mac, 'hex'));
|
||||||
|
|
||||||
let parsedData: Record<string, unknown> = {};
|
let parsedData: Record<string, unknown> = {};
|
||||||
let orderId = '';
|
let orderId = '';
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiParam,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard';
|
import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard';
|
||||||
@@ -29,6 +37,7 @@ import { RefundPaymentDto } from '../dto/refund-payment.dto';
|
|||||||
import { ListTransactionsDto } from '../dto/list-transactions.dto';
|
import { ListTransactionsDto } from '../dto/list-transactions.dto';
|
||||||
import { type PaymentProvider } from '@prisma/client';
|
import { type PaymentProvider } from '@prisma/client';
|
||||||
|
|
||||||
|
@ApiTags('payments')
|
||||||
@Controller('payments')
|
@Controller('payments')
|
||||||
export class PaymentsController {
|
export class PaymentsController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -36,6 +45,11 @@ export class PaymentsController {
|
|||||||
private readonly queryBus: QueryBus,
|
private readonly queryBus: QueryBus,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Create a new payment' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Payment created successfully' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Bad request' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post()
|
@Post()
|
||||||
async createPayment(
|
async createPayment(
|
||||||
@@ -58,6 +72,10 @@ export class PaymentsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Handle payment provider callback (webhook)' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Callback processed successfully' })
|
||||||
|
@ApiParam({ name: 'provider', enum: ['vnpay', 'momo', 'zalopay'] })
|
||||||
|
@Throttle({ 'payment-callback': { ttl: 60_000, limit: 20 } })
|
||||||
@Post('callback/:provider')
|
@Post('callback/:provider')
|
||||||
async handleCallback(
|
async handleCallback(
|
||||||
@Param('provider') provider: string,
|
@Param('provider') provider: string,
|
||||||
@@ -72,6 +90,11 @@ export class PaymentsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get payment status by ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Payment status retrieved' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Payment not found' })
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async getPaymentStatus(
|
async getPaymentStatus(
|
||||||
@@ -81,6 +104,10 @@ export class PaymentsController {
|
|||||||
return this.queryBus.execute(new GetPaymentStatusQuery(id, user.sub));
|
return this.queryBus.execute(new GetPaymentStatusQuery(id, user.sub));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'List transactions for the authenticated user' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Transactions retrieved' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get()
|
@Get()
|
||||||
async listTransactions(
|
async listTransactions(
|
||||||
@@ -92,6 +119,12 @@ export class PaymentsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Refund a payment (admin only)' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Refund initiated successfully' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Forbidden — admin role required' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Payment not found' })
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles('ADMIN')
|
@Roles('ADMIN')
|
||||||
@Post(':id/refund')
|
@Post(':id/refund')
|
||||||
|
|||||||
Reference in New Issue
Block a user