Commit f2844197 authored by ThinhNC's avatar ThinhNC

feat: implement core backend modules, middlewares, route handlers, and database schema

parent c9ef2c74
This diff is collapsed.
...@@ -8,6 +8,7 @@ const config: Config = { ...@@ -8,6 +8,7 @@ const config: Config = {
transform: { transform: {
'^.+\\.tsx?$': 'ts-jest', '^.+\\.tsx?$': 'ts-jest',
}, },
setupFiles: ['<rootDir>/tests/setup.ts'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'], setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
verbose: true, verbose: true,
forceExit: true, forceExit: true,
......
...@@ -228,7 +228,6 @@ model Wallet { ...@@ -228,7 +228,6 @@ model Wallet {
recurringTransactionSchedules RecurringTransactionSchedule[] recurringTransactionSchedules RecurringTransactionSchedule[]
@@unique([userId, name]) @@unique([userId, name])
@@index([userId])
@@index([userId, isArchived]) @@index([userId, isArchived])
@@map("wallets") @@map("wallets")
} }
...@@ -279,12 +278,12 @@ model Transaction { ...@@ -279,12 +278,12 @@ model Transaction {
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict) category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
recurringOccurrence RecurringTransactionOccurrence? recurringOccurrence RecurringTransactionOccurrence?
@@index([userId])
@@index([walletId]) @@index([walletId])
@@index([categoryId]) @@index([categoryId])
@@index([date]) @@index([date])
@@index([userId, date]) @@index([userId, date])
@@index([userId, type]) @@index([userId, type])
@@index([userId, type, date])
@@index([walletId, date]) @@index([walletId, date])
@@index([categoryId, date]) @@index([categoryId, date])
@@index([userId, type, createdAt]) @@index([userId, type, createdAt])
...@@ -358,7 +357,6 @@ model Budget { ...@@ -358,7 +357,6 @@ model Budget {
@@index([userId, type, period]) @@index([userId, type, period])
@@index([isArchived, startDate, endDate]) @@index([isArchived, startDate, endDate])
@@index([userId, isRecurring, autoRenew]) @@index([userId, isRecurring, autoRenew])
@@index([recurrenceGroupId, startDate])
@@unique([recurrenceGroupId, startDate]) @@unique([recurrenceGroupId, startDate])
@@map("budgets") @@map("budgets")
} }
...@@ -415,7 +413,6 @@ model RefreshToken { ...@@ -415,7 +413,6 @@ model RefreshToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, expiresAt]) @@index([userId, expiresAt])
@@index([expiresAt]) @@index([expiresAt])
@@map("refresh_tokens") @@map("refresh_tokens")
...@@ -444,7 +441,6 @@ model VerificationToken { ...@@ -444,7 +441,6 @@ model VerificationToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, expiresAt]) @@index([userId, expiresAt])
@@index([expiresAt]) @@index([expiresAt])
@@map("verification_tokens") @@map("verification_tokens")
...@@ -459,7 +455,6 @@ model PasswordResetToken { ...@@ -459,7 +455,6 @@ model PasswordResetToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, expiresAt]) @@index([userId, expiresAt])
@@index([expiresAt]) @@index([expiresAt])
@@map("password_reset_tokens") @@map("password_reset_tokens")
...@@ -721,7 +716,6 @@ model ApiKey { ...@@ -721,7 +716,6 @@ model ApiKey {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status, deletedAt]) @@index([userId, status, deletedAt])
@@index([keyHash])
@@map("api_keys") @@map("api_keys")
} }
...@@ -804,6 +798,7 @@ model AsyncJob { ...@@ -804,6 +798,7 @@ model AsyncJob {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status, createdAt]) @@index([userId, status, createdAt])
@@index([userId, createdAt(sort: Desc)])
@@index([status, createdAt]) @@index([status, createdAt])
@@map("async_jobs") @@map("async_jobs")
} }
......
const isProduction = (process.env.NODE_ENV || 'development') === 'production';
if (isProduction) {
const accessSecret = process.env.JWT_ACCESS_SECRET;
const refreshSecret = process.env.JWT_REFRESH_SECRET;
if (!accessSecret || accessSecret.includes('default') || accessSecret.length < 32) {
throw new Error(
'FATAL: JWT_ACCESS_SECRET must be configured with at least 32 characters and cannot use default values in production.',
);
}
if (!refreshSecret || refreshSecret.includes('default') || refreshSecret.length < 32) {
throw new Error(
'FATAL: JWT_REFRESH_SECRET must be configured with at least 32 characters and cannot use default values in production.',
);
}
}
export const envConfig = { export const envConfig = {
nodeEnv: process.env.NODE_ENV || 'development', nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '8888', 10), port: parseInt(process.env.PORT || '8888', 10),
......
...@@ -111,7 +111,8 @@ describe('apiKeyMiddleware', () => { ...@@ -111,7 +111,8 @@ describe('apiKeyMiddleware', () => {
it('should allow request when client IP is in ipWhitelist', async () => { it('should allow request when client IP is in ipWhitelist', async () => {
const { rawKey, keyHash, keyPrefix } = generateApiKey(); const { rawKey, keyHash, keyPrefix } = generateApiKey();
req.headers = { 'x-api-key': rawKey, 'x-forwarded-for': '203.113.130.1' }; req.headers = { 'x-api-key': rawKey };
(req as any).ip = '203.113.130.1';
const mockApiKey = { const mockApiKey = {
id: 'key-123', id: 'key-123',
...@@ -143,7 +144,8 @@ describe('apiKeyMiddleware', () => { ...@@ -143,7 +144,8 @@ describe('apiKeyMiddleware', () => {
it('should reject with FORBIDDEN when client IP is not in ipWhitelist', async () => { it('should reject with FORBIDDEN when client IP is not in ipWhitelist', async () => {
const { rawKey, keyHash, keyPrefix } = generateApiKey(); const { rawKey, keyHash, keyPrefix } = generateApiKey();
req.headers = { 'x-api-key': rawKey, 'x-forwarded-for': '1.2.3.4' }; req.headers = { 'x-api-key': rawKey };
(req as any).ip = '1.2.3.4';
const mockApiKey = { const mockApiKey = {
id: 'key-123', id: 'key-123',
......
...@@ -66,8 +66,7 @@ export async function apiKeyMiddleware( ...@@ -66,8 +66,7 @@ export async function apiKeyMiddleware(
if (apiKey.ipWhitelist && apiKey.ipWhitelist.length > 0) { if (apiKey.ipWhitelist && apiKey.ipWhitelist.length > 0) {
const isWildcard = apiKey.ipWhitelist.some((ip) => ip.trim() === '*'); const isWildcard = apiKey.ipWhitelist.some((ip) => ip.trim() === '*');
if (!isWildcard) { if (!isWildcard) {
const forwarded = req.headers['x-forwarded-for']; const rawIp = req.ip || req.socket?.remoteAddress || '';
const rawIp = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : (req.ip || req.socket.remoteAddress || '');
const cleanClientIp = rawIp.replace(/^::ffff:/, ''); const cleanClientIp = rawIp.replace(/^::ffff:/, '');
const isAllowed = apiKey.ipWhitelist.some((allowedIp) => { const isAllowed = apiKey.ipWhitelist.some((allowedIp) => {
......
...@@ -20,7 +20,9 @@ export async function authMiddleware( ...@@ -20,7 +20,9 @@ export async function authMiddleware(
} }
} }
if (!token && typeof req.query?.token === 'string') { // Allow query token ONLY for EventSource / SSE connection endpoints
const isSseRequest = req.path.endsWith('/stream') || req.headers.accept?.includes('text/event-stream');
if (!token && isSseRequest && typeof req.query?.token === 'string') {
token = req.query.token; token = req.query.token;
} }
......
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
import { AppError } from '../common/errors/app-error'; import { AppError } from '../common/errors/app-error';
import { ERROR_CODE } from '../common/errors/error-code'; import { ERROR_CODE } from '../common/errors/error-code';
...@@ -25,6 +26,19 @@ export function errorMiddleware( ...@@ -25,6 +26,19 @@ export function errorMiddleware(
return; return;
} }
if (error instanceof ZodError) {
res.status(422).json({
success: false,
message: 'Validation failed',
code: ERROR_CODE.VALIDATION_ERROR,
errors: error.errors.map((e) => ({
field: e.path.join('.'),
message: e.message,
})),
});
return;
}
console.error('[Unhandled Error]', error); console.error('[Unhandled Error]', error);
res.status(500).json({ res.status(500).json({
......
...@@ -2,14 +2,21 @@ import { Router } from 'express'; ...@@ -2,14 +2,21 @@ import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants'; import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware'; import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { AnomalyController } from './anomaly.controller'; import { AnomalyController } from './anomaly.controller';
import { evaluateAnomalySchema } from './anomaly.validation';
const router = Router(); const router = Router();
const controller = new AnomalyController(); const controller = new AnomalyController();
router.use(authMiddleware); router.use(authMiddleware);
router.post('/evaluate', requirePermission(PERMISSIONS.ANOMALY_EVALUATE), controller.evaluate); router.post(
'/evaluate',
requirePermission(PERMISSIONS.ANOMALY_EVALUATE),
validate(evaluateAnomalySchema),
controller.evaluate,
);
router.get('/recent', requirePermission(PERMISSIONS.ANOMALY_READ), controller.getRecent); router.get('/recent', requirePermission(PERMISSIONS.ANOMALY_READ), controller.getRecent);
export default router; export default router;
...@@ -28,7 +28,8 @@ export class AnomalyService { ...@@ -28,7 +28,8 @@ export class AnomalyService {
input: EvaluateAnomalyInputDto, input: EvaluateAnomalyInputDto,
): Promise<AnomalyEvaluationResultDto> { ): Promise<AnomalyEvaluationResultDto> {
await this.ensureAnomaliesEnabled(); await this.ensureAnomaliesEnabled();
const amountNum = parseFloat(input.amount); const parsedAmount = parseFloat(input.amount);
const amountNum = Number.isFinite(parsedAmount) ? Math.max(0, parsedAmount) : 0;
const dateObj = input.occurredAt ?? new Date(); const dateObj = input.occurredAt ?? new Date();
...@@ -78,7 +79,8 @@ export class AnomalyService { ...@@ -78,7 +79,8 @@ export class AnomalyService {
const flagged: FlaggedAnomalyTransactionDto[] = []; const flagged: FlaggedAnomalyTransactionDto[] = [];
for (const tx of transactions) { for (const tx of transactions) {
const amountNum = parseFloat(tx.amount); const parsedAmount = parseFloat(tx.amount);
const amountNum = Number.isFinite(parsedAmount) ? Math.max(0, parsedAmount) : 0;
const dateObj = tx.createdAt ? new Date(tx.createdAt) : new Date(); const dateObj = tx.createdAt ? new Date(tx.createdAt) : new Date();
const hourOfDayVietnam = (dateObj.getUTCHours() + 7) % 24; const hourOfDayVietnam = (dateObj.getUTCHours() + 7) % 24;
......
...@@ -25,13 +25,15 @@ export class ApiKeyRepository { ...@@ -25,13 +25,15 @@ export class ApiKeyRepository {
}); });
} }
async findByUserId(userId: string): Promise<ApiKey[]> { async findByUserId(userId: string, limit = 50, skip = 0): Promise<ApiKey[]> {
return prisma.apiKey.findMany({ return prisma.apiKey.findMany({
where: { where: {
userId, userId,
deletedAt: null, deletedAt: null,
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: limit,
skip,
}); });
} }
......
...@@ -18,7 +18,7 @@ export class AuthController { ...@@ -18,7 +18,7 @@ export class AuthController {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'strict', sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000, // 24 hours maxAge: 30 * 60 * 1000, // 30 minutes (matches JWT_ACCESS_EXPIRES_IN)
}); });
res.cookie('refreshToken', result.refreshToken, { res.cookie('refreshToken', result.refreshToken, {
...@@ -69,7 +69,7 @@ export class AuthController { ...@@ -69,7 +69,7 @@ export class AuthController {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'strict', sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000, maxAge: 30 * 60 * 1000, // 30 minutes
}); });
res.cookie('refreshToken', result.refreshToken, { res.cookie('refreshToken', result.refreshToken, {
...@@ -301,7 +301,7 @@ export class AuthController { ...@@ -301,7 +301,7 @@ export class AuthController {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'strict', sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000, maxAge: 30 * 60 * 1000, // 30 minutes
}); });
res.cookie('refreshToken', result.refreshToken, { res.cookie('refreshToken', result.refreshToken, {
......
...@@ -476,6 +476,8 @@ export class AuthService { ...@@ -476,6 +476,8 @@ export class AuthService {
hasPhoneNumber: Boolean(dto.phoneNumber), hasPhoneNumber: Boolean(dto.phoneNumber),
}); });
let isPhoneVerified = false;
try { try {
const zaloProfile = await this.fetchZaloProfile(accessToken, appsecretProof); const zaloProfile = await this.fetchZaloProfile(accessToken, appsecretProof);
console.log('[ZaloAuth] fetchZaloProfile response:', zaloProfile); console.log('[ZaloAuth] fetchZaloProfile response:', zaloProfile);
...@@ -485,7 +487,7 @@ export class AuthService { ...@@ -485,7 +487,7 @@ export class AuthService {
if (zaloProfile.picture?.data?.url) zaloAvatarUrl = zaloProfile.picture.data.url; if (zaloProfile.picture?.data?.url) zaloAvatarUrl = zaloProfile.picture.data.url;
} else if (zaloProfile?.error === -501) { } else if (zaloProfile?.error === -501) {
console.warn('[ZaloAuth] Server IP is outside Vietnam (-501). Using client profile info.'); console.warn('[ZaloAuth] Server IP is outside Vietnam (-501). Using client profile info.');
} else if (zaloProfile && zaloProfile.error !== undefined && zaloProfile.error !== 0 && !dto.phoneToken) { } else if (zaloProfile && zaloProfile.error !== undefined && zaloProfile.error !== 0) {
console.error('[ZaloAuth] fetchZaloProfile failed:', zaloProfile); console.error('[ZaloAuth] fetchZaloProfile failed:', zaloProfile);
throw new AppError( throw new AppError(
zaloProfile?.message ? `Zalo Profile Error: ${zaloProfile.message}` : 'Invalid Zalo access token', zaloProfile?.message ? `Zalo Profile Error: ${zaloProfile.message}` : 'Invalid Zalo access token',
...@@ -507,6 +509,7 @@ export class AuthService { ...@@ -507,6 +509,7 @@ export class AuthService {
console.log('[ZaloAuth] fetchZaloPhoneNumber response:', phoneResponse); console.log('[ZaloAuth] fetchZaloPhoneNumber response:', phoneResponse);
if (phoneResponse && phoneResponse.data?.number) { if (phoneResponse && phoneResponse.data?.number) {
resolvedPhone = phoneResponse.data.number; resolvedPhone = phoneResponse.data.number;
isPhoneVerified = true;
} else if (phoneResponse?.error === -501) { } else if (phoneResponse?.error === -501) {
console.warn('[ZaloAuth] Zalo Phone API limited by IP location (-501). Authenticating via Zalo ID.'); console.warn('[ZaloAuth] Zalo Phone API limited by IP location (-501). Authenticating via Zalo ID.');
} else if (phoneResponse && phoneResponse.error !== undefined && phoneResponse.error !== 0) { } else if (phoneResponse && phoneResponse.error !== undefined && phoneResponse.error !== 0) {
...@@ -524,7 +527,7 @@ export class AuthService { ...@@ -524,7 +527,7 @@ export class AuthService {
// Đảm bảo luôn có Zalo ID làm mã định danh tài khoản // Đảm bảo luôn có Zalo ID làm mã định danh tài khoản
if (!zaloId) { if (!zaloId) {
if (resolvedPhone) { if (resolvedPhone && isPhoneVerified) {
zaloId = `zalo_${resolvedPhone}`; zaloId = `zalo_${resolvedPhone}`;
} else if (dto.phoneToken) { } else if (dto.phoneToken) {
const tokenHash = crypto.createHash('sha256').update(dto.phoneToken).digest('hex').substring(0, 16); const tokenHash = crypto.createHash('sha256').update(dto.phoneToken).digest('hex').substring(0, 16);
...@@ -539,11 +542,22 @@ export class AuthService { ...@@ -539,11 +542,22 @@ export class AuthService {
// A. Tìm theo liên kết mạng xã hội Zalo ID trước // A. Tìm theo liên kết mạng xã hội Zalo ID trước
let user: any = await this.repository.findBySocial('zalo', zaloId); let user: any = await this.repository.findBySocial('zalo', zaloId);
// B. Nếu chưa tìm thấy theo Zalo ID và có SĐT, tìm theo SĐT // B. Nếu chưa tìm thấy theo Zalo ID và có SĐT
if (!user && resolvedPhone) { if (!user && resolvedPhone) {
user = await this.repository.findByPhone(resolvedPhone); const existingUser = await this.repository.findByPhone(resolvedPhone);
if (user) { if (existingUser) {
// CHỈ cho phép liên kết tài khoản khi số điện thoại đã được xác thực từ máy chủ Zalo
if (isPhoneVerified) {
user = existingUser;
await this.repository.linkSocialAccount(user.id, 'zalo', zaloId); await this.repository.linkSocialAccount(user.id, 'zalo', zaloId);
} else {
// Ngăn chặn Account Takeover nếu số điện thoại chưa được xác thực
throw new AppError(
'Số điện thoại đã được đăng ký. Vui lòng đăng nhập bằng mật khẩu hoặc xác thực qua Zalo phone token.',
409,
ERROR_CODE.DUPLICATE_ENTRY,
);
}
} }
} }
...@@ -683,6 +697,9 @@ export class AuthService { ...@@ -683,6 +697,9 @@ export class AuthService {
} }
}); });
}); });
req.setTimeout(5000, () => {
req.destroy(new Error('Zalo API request timeout'));
});
req.on('error', reject); req.on('error', reject);
req.end(); req.end();
}); });
...@@ -726,6 +743,9 @@ export class AuthService { ...@@ -726,6 +743,9 @@ export class AuthService {
} }
}); });
}); });
req.setTimeout(5000, () => {
req.destroy(new Error('Zalo Phone API request timeout'));
});
req.on('error', reject); req.on('error', reject);
req.end(); req.end();
}); });
......
import { Router } from 'express'; import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants'; import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authOrApiKeyMiddleware } from '../../middlewares/api-key.middleware';
import { apiKeyRateLimitMiddleware } from '../../middlewares/api-key-rate-limit.middleware';
import { requirePermission } from '../../middlewares/permission.middleware'; import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware'; import { validate } from '../../middlewares/validate.middleware';
import { BudgetController } from './budget.controller'; import { BudgetController } from './budget.controller';
...@@ -15,7 +16,8 @@ import { ...@@ -15,7 +16,8 @@ import {
const router = Router(); const router = Router();
const controller = new BudgetController(); const controller = new BudgetController();
router.use(authMiddleware); router.use(authOrApiKeyMiddleware);
router.use(apiKeyRateLimitMiddleware);
router.get('/', requirePermission(PERMISSIONS.BUDGET_READ), validate(findBudgetsSchema, 'query'), controller.findAll); router.get('/', requirePermission(PERMISSIONS.BUDGET_READ), validate(findBudgetsSchema, 'query'), controller.findAll);
router.post('/', requirePermission(PERMISSIONS.BUDGET_CREATE), validate(createBudgetSchema), controller.create); router.post('/', requirePermission(PERMISSIONS.BUDGET_CREATE), validate(createBudgetSchema), controller.create);
......
...@@ -40,10 +40,6 @@ export class BudgetService { ...@@ -40,10 +40,6 @@ export class BudgetService {
private readonly notificationService = new NotificationService(); private readonly notificationService = new NotificationService();
async findAll(userId: string, query: BudgetQueryDto) { async findAll(userId: string, query: BudgetQueryDto) {
// JIT: check if any due recurring budgets for this user need catch-up
const today = instantToBusinessDate(new Date());
await this.catchUpDueBudgetsForUser(userId, today);
const result = await this.repository.findAll(userId, query); const result = await this.repository.findAll(userId, query);
const summaries = await this.repository.getBatchSpendingSummaries( const summaries = await this.repository.getBatchSpendingSummaries(
userId, userId,
......
...@@ -2,7 +2,9 @@ import { Router } from 'express'; ...@@ -2,7 +2,9 @@ import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants'; import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware'; import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { ForecastController } from './forecast.controller'; import { ForecastController } from './forecast.controller';
import { budgetDepletionQuerySchema, forecastQuerySchema } from './forecast.validation';
const router = Router(); const router = Router();
const controller = new ForecastController(); const controller = new ForecastController();
...@@ -10,7 +12,7 @@ const controller = new ForecastController(); ...@@ -10,7 +12,7 @@ const controller = new ForecastController();
router.use(authMiddleware); router.use(authMiddleware);
router.use(requirePermission(PERMISSIONS.FORECAST_READ)); router.use(requirePermission(PERMISSIONS.FORECAST_READ));
router.get('/runway', controller.getRunway); router.get('/runway', validate(forecastQuerySchema, 'query'), controller.getRunway);
router.get('/budget-depletion', controller.getBudgetDepletion); router.get('/budget-depletion', validate(budgetDepletionQuerySchema, 'query'), controller.getBudgetDepletion);
export default router; export default router;
...@@ -28,11 +28,12 @@ export class JobRepository { ...@@ -28,11 +28,12 @@ export class JobRepository {
}); });
} }
async findByUserId(userId: string, limit = 20): Promise<AsyncJob[]> { async findByUserId(userId: string, limit = 20, skip = 0): Promise<AsyncJob[]> {
return prisma.asyncJob.findMany({ return prisma.asyncJob.findMany({
where: { userId }, where: { userId },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: limit, take: limit,
skip,
}); });
} }
......
...@@ -6,6 +6,7 @@ import { validate } from '../../middlewares/validate.middleware'; ...@@ -6,6 +6,7 @@ import { validate } from '../../middlewares/validate.middleware';
import { adminNotificationController } from './admin-notification.controller'; import { adminNotificationController } from './admin-notification.controller';
import { import {
adminDeliveryQuerySchema, adminDeliveryQuerySchema,
adminParamIdSchema,
adminTemplateQuerySchema, adminTemplateQuerySchema,
notificationOverviewQuerySchema, notificationOverviewQuerySchema,
updateChannelConfigSchema, updateChannelConfigSchema,
...@@ -33,6 +34,7 @@ adminNotificationsRouter.get( ...@@ -33,6 +34,7 @@ adminNotificationsRouter.get(
adminNotificationsRouter.post( adminNotificationsRouter.post(
'/deliveries/:id/retry', '/deliveries/:id/retry',
requirePermission(PERMISSIONS.NOTIFICATION_RETRY), requirePermission(PERMISSIONS.NOTIFICATION_RETRY),
validate(adminParamIdSchema, 'params'),
adminNotificationController.retryDelivery, adminNotificationController.retryDelivery,
); );
...@@ -46,6 +48,7 @@ adminNotificationsRouter.get( ...@@ -46,6 +48,7 @@ adminNotificationsRouter.get(
adminNotificationsRouter.put( adminNotificationsRouter.put(
'/templates/:id', '/templates/:id',
requirePermission(PERMISSIONS.NOTIFICATION_TEMPLATE_UPDATE), requirePermission(PERMISSIONS.NOTIFICATION_TEMPLATE_UPDATE),
validate(adminParamIdSchema, 'params'),
validate(updateTemplateSchema), validate(updateTemplateSchema),
adminNotificationController.updateTemplate, adminNotificationController.updateTemplate,
); );
......
...@@ -46,3 +46,7 @@ export const updateChannelConfigSchema = z.object({ ...@@ -46,3 +46,7 @@ export const updateChannelConfigSchema = z.object({
zaloEnabled: z.boolean().optional(), zaloEnabled: z.boolean().optional(),
pushEnabled: z.boolean().optional(), pushEnabled: z.boolean().optional(),
}); });
export const adminParamIdSchema = z.object({
id: z.string().uuid('Invalid ID parameter format'),
});
...@@ -205,11 +205,17 @@ export class NotificationStreamService { ...@@ -205,11 +205,17 @@ export class NotificationStreamService {
} }
private writeEvent(res: Response, event: string, data: any): void { private writeEvent(res: Response, event: string, data: any): void {
try {
if (!res.writableEnded && !res.destroyed) {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
if (typeof (res as any).flush === 'function') { if (typeof (res as any).flush === 'function') {
(res as any).flush(); (res as any).flush();
} }
} }
} catch (err) {
this.logger.warn(`Failed to write SSE event '${event}':`, err);
}
}
getActiveConnectionCount(userId?: string): number { getActiveConnectionCount(userId?: string): number {
if (userId) { if (userId) {
......
...@@ -7,6 +7,7 @@ import { BudgetService } from '../budgets/budget.service'; ...@@ -7,6 +7,7 @@ import { BudgetService } from '../budgets/budget.service';
import { SubscriptionService } from '../subscriptions/subscription.service'; import { SubscriptionService } from '../subscriptions/subscription.service';
import { lockService } from '../../common/services/lock.service'; import { lockService } from '../../common/services/lock.service';
import { prisma } from '../../database/prisma.client';
export class NotificationWorker { export class NotificationWorker {
private readonly reminderService = new ReminderService(); private readonly reminderService = new ReminderService();
...@@ -19,6 +20,7 @@ export class NotificationWorker { ...@@ -19,6 +20,7 @@ export class NotificationWorker {
private running = false; private running = false;
private lastFinancialScanAt = 0; private lastFinancialScanAt = 0;
private lastSubscriptionScanAt = 0; private lastSubscriptionScanAt = 0;
private lastTokenCleanupAt = 0;
start() { start() {
if (!envConfig.notifications.workerEnabled || this.timer) { if (!envConfig.notifications.workerEnabled || this.timer) {
...@@ -111,6 +113,20 @@ export class NotificationWorker { ...@@ -111,6 +113,20 @@ export class NotificationWorker {
console.error('Notification worker failed to scan subscriptions', error); console.error('Notification worker failed to scan subscriptions', error);
} }
} }
// Clean up expired tokens once every 24 hours (86_400_000 ms)
if (now.getTime() - this.lastTokenCleanupAt >= 24 * 60 * 60 * 1000) {
try {
await Promise.all([
prisma.verificationToken.deleteMany({ where: { expiresAt: { lt: now } } }),
prisma.passwordResetToken.deleteMany({ where: { expiresAt: { lt: now } } }),
prisma.refreshToken.deleteMany({ where: { expiresAt: { lt: now } } }),
]);
this.lastTokenCleanupAt = now.getTime();
} catch (error) {
console.error('Notification worker failed to clean up expired tokens', error);
}
}
} finally { } finally {
this.running = false; this.running = false;
await lockService.release(lockKey, lockToken); await lockService.release(lockKey, lockToken);
......
...@@ -296,7 +296,7 @@ export class QueryCompiler { ...@@ -296,7 +296,7 @@ export class QueryCompiler {
})); }));
} }
// Generate Natural Language Summary // Generate Natural Language Summary with Multi-Currency Awareness
const typeLabel = const typeLabel =
ast.transactionType === 'INCOME' ast.transactionType === 'INCOME'
? 'thu nhập' ? 'thu nhập'
...@@ -308,20 +308,47 @@ export class QueryCompiler { ...@@ -308,20 +308,47 @@ export class QueryCompiler {
? `ví ${ast.walletNames.join(', ')}` ? `ví ${ast.walletNames.join(', ')}`
: ''; : '';
let resolvedCurrency = 'VND'; // Group by wallet to resolve distinct currencies
if (ast.walletIds && ast.walletIds.length === 1) { const walletAggregations = await prisma.transaction.groupBy({
const queriedWallet = await prisma.wallet.findUnique({ by: ['walletId'],
where: { id: ast.walletIds[0] }, where,
select: { currency: true }, _sum: { amount: true },
_count: { id: true },
}); });
if (queriedWallet?.currency) {
resolvedCurrency = queriedWallet.currency; const queriedWallets = await prisma.wallet.findMany({
} where: { id: { in: walletAggregations.map((w) => w.walletId) } },
select: { id: true, name: true, currency: true },
});
const walletMetaMap = new Map(queriedWallets.map((w) => [w.id, w]));
const currencyTotals = new Map<string, { sum: number; count: number }>();
for (const item of walletAggregations) {
const wallet = walletMetaMap.get(item.walletId);
const cur = wallet?.currency || 'VND';
const existing = currencyTotals.get(cur) || { sum: 0, count: 0 };
existing.sum += item._sum.amount ? item._sum.amount.toNumber() : 0;
existing.count += item._count.id;
currencyTotals.set(cur, existing);
} }
const summary = count === 0 let resolvedCurrency = 'VND';
? `Không tìm thấy giao dịch ${typeLabel} nào ${entityLabel ? `thuộc ${entityLabel} ` : ''}trong khoảng thời gian ${timeRangeDesc}.` let summary: string;
: `Tổng ${typeLabel} ${entityLabel ? `thuộc ${entityLabel} ` : ''}trong ${timeRangeDesc}${totalVal.toLocaleString('vi-VN')} ${resolvedCurrency} qua ${count} giao dịch (bình quân: ${Math.round(avgVal).toLocaleString('vi-VN')} ${resolvedCurrency}/giao dịch).`;
if (count === 0) {
summary = `Không tìm thấy giao dịch ${typeLabel} nào ${entityLabel ? `thuộc ${entityLabel} ` : ''}trong khoảng thời gian ${timeRangeDesc}.`;
} else if (currencyTotals.size > 1) {
resolvedCurrency = 'MULTI';
const currencyBreakdown = Array.from(currencyTotals.entries())
.map(([cur, data]) => `${data.sum.toLocaleString('vi-VN')} ${cur} (${data.count} giao dịch)`)
.join(' và ');
summary = `Tổng ${typeLabel} ${entityLabel ? `thuộc ${entityLabel} ` : ''}trong ${timeRangeDesc} là: ${currencyBreakdown} (tổng cộng ${count} giao dịch).`;
} else {
if (currencyTotals.size === 1) {
resolvedCurrency = Array.from(currencyTotals.keys())[0];
}
summary = `Tổng ${typeLabel} ${entityLabel ? `thuộc ${entityLabel} ` : ''}trong ${timeRangeDesc}${totalVal.toLocaleString('vi-VN')} ${resolvedCurrency} qua ${count} giao dịch (bình quân: ${Math.round(avgVal).toLocaleString('vi-VN')} ${resolvedCurrency}/giao dịch).`;
}
return { return {
summary, summary,
......
...@@ -2,7 +2,9 @@ import { Router } from 'express'; ...@@ -2,7 +2,9 @@ import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants'; import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware'; import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { QueryController } from './query.controller'; import { QueryController } from './query.controller';
import { executeQuerySchema, parseQuerySchema } from './query.validation';
const router = Router(); const router = Router();
const controller = new QueryController(); const controller = new QueryController();
...@@ -10,7 +12,7 @@ const controller = new QueryController(); ...@@ -10,7 +12,7 @@ const controller = new QueryController();
router.use(authMiddleware); router.use(authMiddleware);
router.use(requirePermission(PERMISSIONS.QUERY_EXECUTE)); router.use(requirePermission(PERMISSIONS.QUERY_EXECUTE));
router.post('/parse', controller.parse); router.post('/parse', validate(parseQuerySchema), controller.parse);
router.post('/execute', controller.execute); router.post('/execute', validate(executeQuerySchema), controller.execute);
export default router; export default router;
...@@ -158,12 +158,22 @@ export class RbacRepository { ...@@ -158,12 +158,22 @@ export class RbacRepository {
} }
async getPermissionNamesByRoleId(roleId: string): Promise<string[]> { async getPermissionNamesByRoleId(roleId: string): Promise<string[]> {
try {
const rows = await prisma.$queryRaw<{ name: string }[]>`
SELECT p.name
FROM role_permissions rp
JOIN permissions p ON rp.permission_id = p.id
WHERE rp.role_id = ${roleId}::uuid
`;
return rows.map((r) => r.name);
} catch {
const rolePermissions = await prisma.rolePermission.findMany({ const rolePermissions = await prisma.rolePermission.findMany({
where: { roleId }, where: { roleId },
select: { permission: { select: { name: true } } }, include: { permission: true },
}); });
return rolePermissions.map((rp) => rp.permission.name); return rolePermissions.map((rp) => rp.permission.name);
} }
}
async getUserRoleAndPermissions(userId: string) { async getUserRoleAndPermissions(userId: string) {
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
...@@ -178,15 +188,6 @@ export class RbacRepository { ...@@ -178,15 +188,6 @@ export class RbacRepository {
id: true, id: true,
name: true, name: true,
isSystem: true, isSystem: true,
rolePermissions: {
select: {
permission: {
select: {
name: true,
},
},
},
},
}, },
}, },
}, },
...@@ -194,7 +195,7 @@ export class RbacRepository { ...@@ -194,7 +195,7 @@ export class RbacRepository {
if (!user || !user.role) return null; if (!user || !user.role) return null;
const permissions = user.role.rolePermissions.map((rp) => rp.permission.name); const permissions = await this.getPermissionNamesByRoleId(user.roleId);
return { return {
userId: user.id, userId: user.id,
......
...@@ -15,7 +15,6 @@ import { ...@@ -15,7 +15,6 @@ import {
auditLogQuerySchema, auditLogQuerySchema,
} from './rbac.validation'; } from './rbac.validation';
const router = Router();
const controller = new RbacController(); const controller = new RbacController();
// ========================================== // ==========================================
...@@ -123,5 +122,3 @@ auditLogsRouter.get( ...@@ -123,5 +122,3 @@ auditLogsRouter.get(
validate(auditLogQuerySchema, 'query'), validate(auditLogQuerySchema, 'query'),
controller.findAllAuditLogs controller.findAllAuditLogs
); );
export default router;
import { Router } from 'express'; import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants'; import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authOrApiKeyMiddleware } from '../../middlewares/api-key.middleware';
import { apiKeyRateLimitMiddleware } from '../../middlewares/api-key-rate-limit.middleware';
import { requirePermission } from '../../middlewares/permission.middleware'; import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware'; import { validate } from '../../middlewares/validate.middleware';
import { RecurringTransactionController } from './recurring-transaction.controller'; import { RecurringTransactionController } from './recurring-transaction.controller';
...@@ -16,7 +17,8 @@ import { ...@@ -16,7 +17,8 @@ import {
const router = Router(); const router = Router();
const controller = new RecurringTransactionController(); const controller = new RecurringTransactionController();
router.use(authMiddleware); router.use(authOrApiKeyMiddleware);
router.use(apiKeyRateLimitMiddleware);
router.get('/', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_READ), validate(findRecurringTransactionsSchema, 'query'), controller.findAll); router.get('/', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_READ), validate(findRecurringTransactionsSchema, 'query'), controller.findAll);
router.post('/', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_CREATE), validate(createRecurringTransactionSchema), controller.create); router.post('/', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_CREATE), validate(createRecurringTransactionSchema), controller.create);
router.get('/:id/preview', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_READ), validate(recurringTransactionParamsSchema, 'params'), validate(recurringTransactionPreviewSchema, 'query'), controller.preview); router.get('/:id/preview', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_READ), validate(recurringTransactionParamsSchema, 'params'), validate(recurringTransactionPreviewSchema, 'query'), controller.preview);
......
import { Router } from 'express'; import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants'; import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authOrApiKeyMiddleware } from '../../middlewares/api-key.middleware';
import { apiKeyRateLimitMiddleware } from '../../middlewares/api-key-rate-limit.middleware';
import { requirePermission } from '../../middlewares/permission.middleware'; import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware'; import { validate } from '../../middlewares/validate.middleware';
import { SavingGoalController } from './saving-goal.controller'; import { SavingGoalController } from './saving-goal.controller';
...@@ -18,7 +19,8 @@ import { ...@@ -18,7 +19,8 @@ import {
const router = Router(); const router = Router();
const controller = new SavingGoalController(); const controller = new SavingGoalController();
router.use(authMiddleware); router.use(authOrApiKeyMiddleware);
router.use(apiKeyRateLimitMiddleware);
router.get('/', requirePermission(PERMISSIONS.SAVING_GOAL_READ), validate(findSavingGoalsSchema, 'query'), controller.findAll); router.get('/', requirePermission(PERMISSIONS.SAVING_GOAL_READ), validate(findSavingGoalsSchema, 'query'), controller.findAll);
router.post('/', requirePermission(PERMISSIONS.SAVING_GOAL_CREATE), validate(createSavingGoalSchema), controller.create); router.post('/', requirePermission(PERMISSIONS.SAVING_GOAL_CREATE), validate(createSavingGoalSchema), controller.create);
......
...@@ -2,14 +2,16 @@ import { Router } from 'express'; ...@@ -2,14 +2,16 @@ import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants'; import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware'; import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { SimulationController } from './simulation.controller'; import { SimulationController } from './simulation.controller';
import { runSimulationSchema } from './simulation.validation';
const router = Router(); const router = Router();
const controller = new SimulationController(); const controller = new SimulationController();
router.use(authMiddleware); router.use(authMiddleware);
router.post('/run', requirePermission(PERMISSIONS.SIMULATION_EXECUTE), controller.run); router.post('/run', requirePermission(PERMISSIONS.SIMULATION_EXECUTE), validate(runSimulationSchema), controller.run);
router.get('/presets', requirePermission(PERMISSIONS.SIMULATION_READ), controller.presets); router.get('/presets', requirePermission(PERMISSIONS.SIMULATION_READ), controller.presets);
export default router; export default router;
...@@ -4,7 +4,10 @@ import { authMiddleware } from '../../middlewares/auth.middleware'; ...@@ -4,7 +4,10 @@ import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware'; import { requirePermission } from '../../middlewares/permission.middleware';
import { SubscriptionController } from './subscription.controller'; import { SubscriptionController } from './subscription.controller';
import { validate } from '../../middlewares/validate.middleware'; import { validate } from '../../middlewares/validate.middleware';
import { convertSubscriptionToRecurringTransactionSchema } from './subscription.validation'; import {
convertSubscriptionToRecurringTransactionSchema,
convertSubscriptionToReminderSchema,
} from './subscription.validation';
const router = Router(); const router = Router();
const controller = new SubscriptionController(); const controller = new SubscriptionController();
...@@ -12,7 +15,12 @@ const controller = new SubscriptionController(); ...@@ -12,7 +15,12 @@ const controller = new SubscriptionController();
router.use(authMiddleware); router.use(authMiddleware);
router.get('/discover', requirePermission(PERMISSIONS.SUBSCRIPTION_READ), controller.discover); router.get('/discover', requirePermission(PERMISSIONS.SUBSCRIPTION_READ), controller.discover);
router.post('/convert-to-reminder', requirePermission(PERMISSIONS.SUBSCRIPTION_MANAGE, PERMISSIONS.REMINDER_CREATE), controller.convertToReminder); router.post(
'/convert-to-reminder',
requirePermission(PERMISSIONS.SUBSCRIPTION_MANAGE, PERMISSIONS.REMINDER_CREATE),
validate(convertSubscriptionToReminderSchema),
controller.convertToReminder,
);
router.post( router.post(
'/convert-to-recurring-transaction', '/convert-to-recurring-transaction',
requirePermission(PERMISSIONS.SUBSCRIPTION_MANAGE, PERMISSIONS.RECURRING_TRANSACTION_CREATE), requirePermission(PERMISSIONS.SUBSCRIPTION_MANAGE, PERMISSIONS.RECURRING_TRANSACTION_CREATE),
......
...@@ -12,6 +12,7 @@ import { SubscriptionRepository } from './subscription.repository'; ...@@ -12,6 +12,7 @@ import { SubscriptionRepository } from './subscription.repository';
import { RecurringTransactionService } from '../recurring-transactions/recurring-transaction.service'; import { RecurringTransactionService } from '../recurring-transactions/recurring-transaction.service';
import { ConvertSubscriptionToRecurringTransactionDto } from '../recurring-transactions/recurring-transaction.dto'; import { ConvertSubscriptionToRecurringTransactionDto } from '../recurring-transactions/recurring-transaction.dto';
import { NotificationService } from '../notifications/notification.service'; import { NotificationService } from '../notifications/notification.service';
import { instantToBusinessDate } from '../../common/date-time/business-time';
const SCAN_BATCH_SIZE = 50; const SCAN_BATCH_SIZE = 50;
const SCAN_HISTORY_DAYS = 180; const SCAN_HISTORY_DAYS = 180;
...@@ -67,7 +68,7 @@ export class SubscriptionService { ...@@ -67,7 +68,7 @@ export class SubscriptionService {
let cursor: string | undefined; let cursor: string | undefined;
let usersScanned = 0; let usersScanned = 0;
let notificationsSent = 0; let notificationsSent = 0;
const scanDate = new Date().toISOString().slice(0, 10); // YYYY-MM-DD, resets dedup daily const scanDate = instantToBusinessDate(new Date()); // Business date (Asia/Ho_Chi_Minh), resets dedup daily
do { do {
const { userIds, nextCursor } = await this.repository.findActiveUserIdsBatch( const { userIds, nextCursor } = await this.repository.findActiveUserIdsBatch(
......
...@@ -5,6 +5,7 @@ import { requirePermission } from '../../middlewares/permission.middleware'; ...@@ -5,6 +5,7 @@ import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware'; import { validate } from '../../middlewares/validate.middleware';
import { systemSettingController } from './system-setting.controller'; import { systemSettingController } from './system-setting.controller';
import { import {
systemSettingKeyParamSchema,
systemSettingQuerySchema, systemSettingQuerySchema,
updateMaintenanceModeSchema, updateMaintenanceModeSchema,
updateSystemSettingSchema, updateSystemSettingSchema,
...@@ -31,12 +32,14 @@ adminSettingsRouter.post( ...@@ -31,12 +32,14 @@ adminSettingsRouter.post(
adminSettingsRouter.get( adminSettingsRouter.get(
'/:key', '/:key',
requirePermission(PERMISSIONS.SYSTEM_CONFIG_READ), requirePermission(PERMISSIONS.SYSTEM_CONFIG_READ),
validate(systemSettingKeyParamSchema, 'params'),
systemSettingController.findByKey, systemSettingController.findByKey,
); );
adminSettingsRouter.patch( adminSettingsRouter.patch(
'/:key', '/:key',
requirePermission(PERMISSIONS.SYSTEM_CONFIG_UPDATE), requirePermission(PERMISSIONS.SYSTEM_CONFIG_UPDATE),
validate(systemSettingKeyParamSchema, 'params'),
validate(updateSystemSettingSchema), validate(updateSystemSettingSchema),
systemSettingController.updateSetting, systemSettingController.updateSetting,
); );
......
...@@ -26,3 +26,7 @@ export const updateMaintenanceModeSchema = z.object({ ...@@ -26,3 +26,7 @@ export const updateMaintenanceModeSchema = z.object({
startAt: z.string().trim().nullable().optional(), startAt: z.string().trim().nullable().optional(),
endAt: z.string().trim().nullable().optional(), endAt: z.string().trim().nullable().optional(),
}); });
export const systemSettingKeyParamSchema = z.object({
key: z.string().trim().min(1, 'Key is required').max(100, 'Key is too long'),
});
import { Router } from 'express'; import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants'; import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authOrApiKeyMiddleware } from '../../middlewares/api-key.middleware';
import { apiKeyRateLimitMiddleware } from '../../middlewares/api-key-rate-limit.middleware';
import { requirePermission } from '../../middlewares/permission.middleware'; import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware'; import { validate } from '../../middlewares/validate.middleware';
import { TransferController } from './transfer.controller'; import { TransferController } from './transfer.controller';
...@@ -13,7 +14,8 @@ import { ...@@ -13,7 +14,8 @@ import {
const router = Router(); const router = Router();
const controller = new TransferController(); const controller = new TransferController();
router.use(authMiddleware); router.use(authOrApiKeyMiddleware);
router.use(apiKeyRateLimitMiddleware);
router.get('/', requirePermission(PERMISSIONS.TRANSFER_READ), validate(findTransfersSchema, 'query'), controller.findAll); router.get('/', requirePermission(PERMISSIONS.TRANSFER_READ), validate(findTransfersSchema, 'query'), controller.findAll);
router.post('/', requirePermission(PERMISSIONS.TRANSFER_CREATE), validate(createTransferSchema), controller.create); router.post('/', requirePermission(PERMISSIONS.TRANSFER_CREATE), validate(createTransferSchema), controller.create);
......
...@@ -15,8 +15,17 @@ const amountSchema = z ...@@ -15,8 +15,17 @@ const amountSchema = z
const dateSchema = z const dateSchema = z
.string() .string()
.datetime({ offset: true, message: 'Date must be a valid ISO 8601 date-time' }) .trim()
.transform((value) => new Date(value)); .refine(
(value) => /^\d{4}-\d{2}-\d{2}$/.test(value) || !isNaN(Date.parse(value)),
'Date must be a valid YYYY-MM-DD format or ISO 8601 date-time',
)
.transform((value) => {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return new Date(`${value}T00:00:00+07:00`);
}
return new Date(value);
});
export const transferParamsSchema = z.object({ export const transferParamsSchema = z.object({
id: z.string().uuid('Invalid transfer id'), id: z.string().uuid('Invalid transfer id'),
......
...@@ -21,13 +21,15 @@ export class WebhookRepository { ...@@ -21,13 +21,15 @@ export class WebhookRepository {
}); });
} }
async findByUserId(userId: string): Promise<WebhookEndpoint[]> { async findByUserId(userId: string, limit = 50, skip = 0): Promise<WebhookEndpoint[]> {
return prisma.webhookEndpoint.findMany({ return prisma.webhookEndpoint.findMany({
where: { where: {
userId, userId,
deletedAt: null, deletedAt: null,
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: limit,
skip,
}); });
} }
...@@ -96,11 +98,12 @@ export class WebhookRepository { ...@@ -96,11 +98,12 @@ export class WebhookRepository {
}); });
} }
async findDeliveriesByEndpointId(endpointId: string, limit = 50): Promise<WebhookDelivery[]> { async findDeliveriesByEndpointId(endpointId: string, limit = 50, skip = 0): Promise<WebhookDelivery[]> {
return prisma.webhookDelivery.findMany({ return prisma.webhookDelivery.findMany({
where: { webhookEndpointId: endpointId }, where: { webhookEndpointId: endpointId },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: limit, take: limit,
skip,
}); });
} }
......
import { QueryCompiler } from '../src/modules/query/query-compiler';
import { envConfig } from '../src/config/env.config';
import { findTransfersSchema } from '../src/modules/transfers/transfer.validation';
import { instantToBusinessDate } from '../src/common/date-time/business-time';
import { systemSettingKeyParamSchema } from '../src/modules/system-settings/system-setting.validation';
import { adminParamIdSchema } from '../src/modules/notifications/admin-notification.validation';
describe('Audit Backlog 18 Bug Fixes Verification', () => {
describe('P0-02: Production Secret Validation', () => {
it('should confirm envConfig is initialized and valid', () => {
expect(envConfig.jwt.accessSecret).toBeDefined();
expect(envConfig.jwt.refreshSecret).toBeDefined();
});
});
describe('P0-03: Multi-Currency AI Query Compiler logic', () => {
it('should resolve date ranges deterministically', () => {
const todayRange = QueryCompiler.resolveDateRange({ type: 'TODAY' });
expect(todayRange.description).toBe('Hôm nay');
expect(todayRange.from).toBe(todayRange.to);
const thisWeekRange = QueryCompiler.resolveDateRange({ type: 'THIS_WEEK' });
expect(thisWeekRange.description).toBe('Tuần này');
expect(thisWeekRange.from <= thisWeekRange.to).toBe(true);
});
it('should format multi-currency breakdown accurately without currency loss', () => {
const currencyTotals = new Map<string, { sum: number; count: number }>();
currencyTotals.set('VND', { sum: 150000, count: 2 });
currencyTotals.set('USD', { sum: 20, count: 1 });
expect(currencyTotals.size).toBe(2);
const resolvedCurrency = currencyTotals.size > 1 ? 'MULTI' : 'VND';
expect(resolvedCurrency).toBe('MULTI');
const currencyBreakdown = Array.from(currencyTotals.entries())
.map(([cur, data]) => `${data.sum.toLocaleString('vi-VN')} ${cur} (${data.count} giao dịch)`)
.join(' và ');
expect(currencyBreakdown).toContain('150.000 VND (2 giao dịch)');
expect(currencyBreakdown).toContain('20 USD (1 giao dịch)');
});
});
describe('P1-04: UTC+7 Timezone in Business Date Conversion', () => {
it('should convert UTC midnight to UTC+7 business date correctly', () => {
// 2026-09-12 17:30 UTC is 2026-09-13 00:30 UTC+7
const dateUtc = new Date('2026-09-12T17:30:00.000Z');
const businessDate = instantToBusinessDate(dateUtc);
expect(businessDate).toBe('2026-09-13');
});
});
describe('P2-05: Flexible Date Filter Formats for Transfers', () => {
it('should accept YYYY-MM-DD format', () => {
const res = findTransfersSchema.safeParse({ dateFrom: '2026-09-01', dateTo: '2026-09-30' });
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.dateFrom).toBeInstanceOf(Date);
}
});
it('should accept ISO 8601 full datetime format', () => {
const res = findTransfersSchema.safeParse({
dateFrom: '2026-09-01T00:00:00.000Z',
dateTo: '2026-09-30T23:59:59.999Z',
});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.dateFrom).toBeInstanceOf(Date);
}
});
it('should reject invalid date strings', () => {
const res = findTransfersSchema.safeParse({ dateFrom: 'invalid-date' });
expect(res.success).toBe(false);
});
});
describe('P3-02: Route Parameter Validation Schemas', () => {
it('should validate system setting key', () => {
expect(systemSettingKeyParamSchema.safeParse({ key: 'MAINTENANCE_MODE' }).success).toBe(true);
expect(systemSettingKeyParamSchema.safeParse({ key: '' }).success).toBe(false);
});
it('should validate admin UUID param', () => {
expect(adminParamIdSchema.safeParse({ id: 'c3f3ef80-87b6-4b82-a0b2-3db3ad537b00' }).success).toBe(true);
expect(adminParamIdSchema.safeParse({ id: 'invalid-id' }).success).toBe(false);
});
});
});
...@@ -60,6 +60,11 @@ describe('Zalo Auth Integration Tests', () => { ...@@ -60,6 +60,11 @@ describe('Zalo Auth Integration Tests', () => {
describe('Invalid Zalo Token', () => { describe('Invalid Zalo Token', () => {
it('should return 401 when Zalo access token is invalid', async () => { it('should return 401 when Zalo access token is invalid', async () => {
const mockFetchZalo = jest.spyOn(AuthService.prototype as any, 'fetchZaloProfile').mockResolvedValue({
error: -108,
message: 'Invalid access token',
});
const res = await request(app) const res = await request(app)
.post('/api/v1/auth/zalo-login') .post('/api/v1/auth/zalo-login')
.send({ .send({
...@@ -67,6 +72,8 @@ describe('Zalo Auth Integration Tests', () => { ...@@ -67,6 +72,8 @@ describe('Zalo Auth Integration Tests', () => {
phoneNumber: testPhone, phoneNumber: testPhone,
}); });
mockFetchZalo.mockRestore();
expect(res.status).toBe(401); expect(res.status).toBe(401);
expect(res.body.success).toBe(false); expect(res.body.success).toBe(false);
expect(res.body.code).toBe('INVALID_CREDENTIALS'); expect(res.body.code).toBe('INVALID_CREDENTIALS');
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment