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 = {
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
setupFiles: ['<rootDir>/tests/setup.ts'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
verbose: true,
forceExit: true,
......
......@@ -228,7 +228,6 @@ model Wallet {
recurringTransactionSchedules RecurringTransactionSchedule[]
@@unique([userId, name])
@@index([userId])
@@index([userId, isArchived])
@@map("wallets")
}
......@@ -279,12 +278,12 @@ model Transaction {
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
recurringOccurrence RecurringTransactionOccurrence?
@@index([userId])
@@index([walletId])
@@index([categoryId])
@@index([date])
@@index([userId, date])
@@index([userId, type])
@@index([userId, type, date])
@@index([walletId, date])
@@index([categoryId, date])
@@index([userId, type, createdAt])
......@@ -358,7 +357,6 @@ model Budget {
@@index([userId, type, period])
@@index([isArchived, startDate, endDate])
@@index([userId, isRecurring, autoRenew])
@@index([recurrenceGroupId, startDate])
@@unique([recurrenceGroupId, startDate])
@@map("budgets")
}
......@@ -415,7 +413,6 @@ model RefreshToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, expiresAt])
@@index([expiresAt])
@@map("refresh_tokens")
......@@ -444,7 +441,6 @@ model VerificationToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, expiresAt])
@@index([expiresAt])
@@map("verification_tokens")
......@@ -459,7 +455,6 @@ model PasswordResetToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, expiresAt])
@@index([expiresAt])
@@map("password_reset_tokens")
......@@ -721,7 +716,6 @@ model ApiKey {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status, deletedAt])
@@index([keyHash])
@@map("api_keys")
}
......@@ -804,6 +798,7 @@ model AsyncJob {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status, createdAt])
@@index([userId, createdAt(sort: Desc)])
@@index([status, createdAt])
@@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 = {
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '8888', 10),
......
......@@ -111,7 +111,8 @@ describe('apiKeyMiddleware', () => {
it('should allow request when client IP is in ipWhitelist', async () => {
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 = {
id: 'key-123',
......@@ -143,7 +144,8 @@ describe('apiKeyMiddleware', () => {
it('should reject with FORBIDDEN when client IP is not in ipWhitelist', async () => {
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 = {
id: 'key-123',
......
......@@ -66,8 +66,7 @@ export async function apiKeyMiddleware(
if (apiKey.ipWhitelist && apiKey.ipWhitelist.length > 0) {
const isWildcard = apiKey.ipWhitelist.some((ip) => ip.trim() === '*');
if (!isWildcard) {
const forwarded = req.headers['x-forwarded-for'];
const rawIp = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : (req.ip || req.socket.remoteAddress || '');
const rawIp = req.ip || req.socket?.remoteAddress || '';
const cleanClientIp = rawIp.replace(/^::ffff:/, '');
const isAllowed = apiKey.ipWhitelist.some((allowedIp) => {
......
......@@ -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;
}
......
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
import { AppError } from '../common/errors/app-error';
import { ERROR_CODE } from '../common/errors/error-code';
......@@ -25,6 +26,19 @@ export function errorMiddleware(
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);
res.status(500).json({
......
......@@ -2,14 +2,21 @@ import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { AnomalyController } from './anomaly.controller';
import { evaluateAnomalySchema } from './anomaly.validation';
const router = Router();
const controller = new AnomalyController();
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);
export default router;
......@@ -28,7 +28,8 @@ export class AnomalyService {
input: EvaluateAnomalyInputDto,
): Promise<AnomalyEvaluationResultDto> {
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();
......@@ -78,7 +79,8 @@ export class AnomalyService {
const flagged: FlaggedAnomalyTransactionDto[] = [];
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 hourOfDayVietnam = (dateObj.getUTCHours() + 7) % 24;
......
......@@ -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({
where: {
userId,
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
take: limit,
skip,
});
}
......
......@@ -18,7 +18,7 @@ export class AuthController {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
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, {
......@@ -69,7 +69,7 @@ export class AuthController {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000,
maxAge: 30 * 60 * 1000, // 30 minutes
});
res.cookie('refreshToken', result.refreshToken, {
......@@ -301,7 +301,7 @@ export class AuthController {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000,
maxAge: 30 * 60 * 1000, // 30 minutes
});
res.cookie('refreshToken', result.refreshToken, {
......
......@@ -476,6 +476,8 @@ export class AuthService {
hasPhoneNumber: Boolean(dto.phoneNumber),
});
let isPhoneVerified = false;
try {
const zaloProfile = await this.fetchZaloProfile(accessToken, appsecretProof);
console.log('[ZaloAuth] fetchZaloProfile response:', zaloProfile);
......@@ -485,7 +487,7 @@ export class AuthService {
if (zaloProfile.picture?.data?.url) zaloAvatarUrl = zaloProfile.picture.data.url;
} else if (zaloProfile?.error === -501) {
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);
throw new AppError(
zaloProfile?.message ? `Zalo Profile Error: ${zaloProfile.message}` : 'Invalid Zalo access token',
......@@ -507,6 +509,7 @@ export class AuthService {
console.log('[ZaloAuth] fetchZaloPhoneNumber response:', phoneResponse);
if (phoneResponse && phoneResponse.data?.number) {
resolvedPhone = phoneResponse.data.number;
isPhoneVerified = true;
} else if (phoneResponse?.error === -501) {
console.warn('[ZaloAuth] Zalo Phone API limited by IP location (-501). Authenticating via Zalo ID.');
} else if (phoneResponse && phoneResponse.error !== undefined && phoneResponse.error !== 0) {
......@@ -524,7 +527,7 @@ export class AuthService {
// Đảm bảo luôn có Zalo ID làm mã định danh tài khoản
if (!zaloId) {
if (resolvedPhone) {
if (resolvedPhone && isPhoneVerified) {
zaloId = `zalo_${resolvedPhone}`;
} else if (dto.phoneToken) {
const tokenHash = crypto.createHash('sha256').update(dto.phoneToken).digest('hex').substring(0, 16);
......@@ -539,11 +542,22 @@ export class AuthService {
// 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);
// 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) {
user = await this.repository.findByPhone(resolvedPhone);
if (user) {
await this.repository.linkSocialAccount(user.id, 'zalo', zaloId);
const existingUser = await this.repository.findByPhone(resolvedPhone);
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);
} 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 {
}
});
});
req.setTimeout(5000, () => {
req.destroy(new Error('Zalo API request timeout'));
});
req.on('error', reject);
req.end();
});
......@@ -726,6 +743,9 @@ export class AuthService {
}
});
});
req.setTimeout(5000, () => {
req.destroy(new Error('Zalo Phone API request timeout'));
});
req.on('error', reject);
req.end();
});
......
import { Router } from 'express';
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 { validate } from '../../middlewares/validate.middleware';
import { BudgetController } from './budget.controller';
......@@ -15,7 +16,8 @@ import {
const router = Router();
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.post('/', requirePermission(PERMISSIONS.BUDGET_CREATE), validate(createBudgetSchema), controller.create);
......
......@@ -40,10 +40,6 @@ export class BudgetService {
private readonly notificationService = new NotificationService();
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 summaries = await this.repository.getBatchSpendingSummaries(
userId,
......
......@@ -2,7 +2,9 @@ import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { ForecastController } from './forecast.controller';
import { budgetDepletionQuerySchema, forecastQuerySchema } from './forecast.validation';
const router = Router();
const controller = new ForecastController();
......@@ -10,7 +12,7 @@ const controller = new ForecastController();
router.use(authMiddleware);
router.use(requirePermission(PERMISSIONS.FORECAST_READ));
router.get('/runway', controller.getRunway);
router.get('/budget-depletion', controller.getBudgetDepletion);
router.get('/runway', validate(forecastQuerySchema, 'query'), controller.getRunway);
router.get('/budget-depletion', validate(budgetDepletionQuerySchema, 'query'), controller.getBudgetDepletion);
export default router;
......@@ -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({
where: { userId },
orderBy: { createdAt: 'desc' },
take: limit,
skip,
});
}
......
......@@ -6,6 +6,7 @@ import { validate } from '../../middlewares/validate.middleware';
import { adminNotificationController } from './admin-notification.controller';
import {
adminDeliveryQuerySchema,
adminParamIdSchema,
adminTemplateQuerySchema,
notificationOverviewQuerySchema,
updateChannelConfigSchema,
......@@ -33,6 +34,7 @@ adminNotificationsRouter.get(
adminNotificationsRouter.post(
'/deliveries/:id/retry',
requirePermission(PERMISSIONS.NOTIFICATION_RETRY),
validate(adminParamIdSchema, 'params'),
adminNotificationController.retryDelivery,
);
......@@ -46,6 +48,7 @@ adminNotificationsRouter.get(
adminNotificationsRouter.put(
'/templates/:id',
requirePermission(PERMISSIONS.NOTIFICATION_TEMPLATE_UPDATE),
validate(adminParamIdSchema, 'params'),
validate(updateTemplateSchema),
adminNotificationController.updateTemplate,
);
......
......@@ -46,3 +46,7 @@ export const updateChannelConfigSchema = z.object({
zaloEnabled: z.boolean().optional(),
pushEnabled: z.boolean().optional(),
});
export const adminParamIdSchema = z.object({
id: z.string().uuid('Invalid ID parameter format'),
});
......@@ -205,9 +205,15 @@ export class NotificationStreamService {
}
private writeEvent(res: Response, event: string, data: any): void {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
if (typeof (res as any).flush === 'function') {
(res as any).flush();
try {
if (!res.writableEnded && !res.destroyed) {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
if (typeof (res as any).flush === 'function') {
(res as any).flush();
}
}
} catch (err) {
this.logger.warn(`Failed to write SSE event '${event}':`, err);
}
}
......
......@@ -7,6 +7,7 @@ import { BudgetService } from '../budgets/budget.service';
import { SubscriptionService } from '../subscriptions/subscription.service';
import { lockService } from '../../common/services/lock.service';
import { prisma } from '../../database/prisma.client';
export class NotificationWorker {
private readonly reminderService = new ReminderService();
......@@ -19,6 +20,7 @@ export class NotificationWorker {
private running = false;
private lastFinancialScanAt = 0;
private lastSubscriptionScanAt = 0;
private lastTokenCleanupAt = 0;
start() {
if (!envConfig.notifications.workerEnabled || this.timer) {
......@@ -111,6 +113,20 @@ export class NotificationWorker {
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 {
this.running = false;
await lockService.release(lockKey, lockToken);
......
......@@ -296,7 +296,7 @@ export class QueryCompiler {
}));
}
// Generate Natural Language Summary
// Generate Natural Language Summary with Multi-Currency Awareness
const typeLabel =
ast.transactionType === 'INCOME'
? 'thu nhập'
......@@ -308,21 +308,48 @@ export class QueryCompiler {
? `ví ${ast.walletNames.join(', ')}`
: '';
// Group by wallet to resolve distinct currencies
const walletAggregations = await prisma.transaction.groupBy({
by: ['walletId'],
where,
_sum: { amount: true },
_count: { id: true },
});
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);
}
let resolvedCurrency = 'VND';
if (ast.walletIds && ast.walletIds.length === 1) {
const queriedWallet = await prisma.wallet.findUnique({
where: { id: ast.walletIds[0] },
select: { currency: true },
});
if (queriedWallet?.currency) {
resolvedCurrency = queriedWallet.currency;
let summary: string;
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).`;
}
const summary = count === 0
? `Không tìm thấy giao dịch ${typeLabel} nào ${entityLabel ? `thuộc ${entityLabel} ` : ''}trong khoảng thời gian ${timeRangeDesc}.`
: `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 {
summary,
timeRangeDescription: timeRangeDesc,
......
......@@ -2,7 +2,9 @@ import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { QueryController } from './query.controller';
import { executeQuerySchema, parseQuerySchema } from './query.validation';
const router = Router();
const controller = new QueryController();
......@@ -10,7 +12,7 @@ const controller = new QueryController();
router.use(authMiddleware);
router.use(requirePermission(PERMISSIONS.QUERY_EXECUTE));
router.post('/parse', controller.parse);
router.post('/execute', controller.execute);
router.post('/parse', validate(parseQuerySchema), controller.parse);
router.post('/execute', validate(executeQuerySchema), controller.execute);
export default router;
......@@ -158,11 +158,21 @@ export class RbacRepository {
}
async getPermissionNamesByRoleId(roleId: string): Promise<string[]> {
const rolePermissions = await prisma.rolePermission.findMany({
where: { roleId },
select: { permission: { select: { name: true } } },
});
return rolePermissions.map((rp) => rp.permission.name);
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({
where: { roleId },
include: { permission: true },
});
return rolePermissions.map((rp) => rp.permission.name);
}
}
async getUserRoleAndPermissions(userId: string) {
......@@ -178,15 +188,6 @@ export class RbacRepository {
id: true,
name: true,
isSystem: true,
rolePermissions: {
select: {
permission: {
select: {
name: true,
},
},
},
},
},
},
},
......@@ -194,7 +195,7 @@ export class RbacRepository {
if (!user || !user.role) return null;
const permissions = user.role.rolePermissions.map((rp) => rp.permission.name);
const permissions = await this.getPermissionNamesByRoleId(user.roleId);
return {
userId: user.id,
......
......@@ -15,7 +15,6 @@ import {
auditLogQuerySchema,
} from './rbac.validation';
const router = Router();
const controller = new RbacController();
// ==========================================
......@@ -123,5 +122,3 @@ auditLogsRouter.get(
validate(auditLogQuerySchema, 'query'),
controller.findAllAuditLogs
);
export default router;
import { Router } from 'express';
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 { validate } from '../../middlewares/validate.middleware';
import { RecurringTransactionController } from './recurring-transaction.controller';
......@@ -16,7 +17,8 @@ import {
const router = Router();
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.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);
......
import { Router } from 'express';
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 { validate } from '../../middlewares/validate.middleware';
import { SavingGoalController } from './saving-goal.controller';
......@@ -18,7 +19,8 @@ import {
const router = Router();
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.post('/', requirePermission(PERMISSIONS.SAVING_GOAL_CREATE), validate(createSavingGoalSchema), controller.create);
......
......@@ -2,14 +2,16 @@ import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { SimulationController } from './simulation.controller';
import { runSimulationSchema } from './simulation.validation';
const router = Router();
const controller = new SimulationController();
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);
export default router;
......@@ -4,7 +4,10 @@ import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { SubscriptionController } from './subscription.controller';
import { validate } from '../../middlewares/validate.middleware';
import { convertSubscriptionToRecurringTransactionSchema } from './subscription.validation';
import {
convertSubscriptionToRecurringTransactionSchema,
convertSubscriptionToReminderSchema,
} from './subscription.validation';
const router = Router();
const controller = new SubscriptionController();
......@@ -12,7 +15,12 @@ const controller = new SubscriptionController();
router.use(authMiddleware);
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(
'/convert-to-recurring-transaction',
requirePermission(PERMISSIONS.SUBSCRIPTION_MANAGE, PERMISSIONS.RECURRING_TRANSACTION_CREATE),
......
......@@ -12,6 +12,7 @@ import { SubscriptionRepository } from './subscription.repository';
import { RecurringTransactionService } from '../recurring-transactions/recurring-transaction.service';
import { ConvertSubscriptionToRecurringTransactionDto } from '../recurring-transactions/recurring-transaction.dto';
import { NotificationService } from '../notifications/notification.service';
import { instantToBusinessDate } from '../../common/date-time/business-time';
const SCAN_BATCH_SIZE = 50;
const SCAN_HISTORY_DAYS = 180;
......@@ -67,7 +68,7 @@ export class SubscriptionService {
let cursor: string | undefined;
let usersScanned = 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 {
const { userIds, nextCursor } = await this.repository.findActiveUserIdsBatch(
......
......@@ -5,6 +5,7 @@ import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { systemSettingController } from './system-setting.controller';
import {
systemSettingKeyParamSchema,
systemSettingQuerySchema,
updateMaintenanceModeSchema,
updateSystemSettingSchema,
......@@ -31,12 +32,14 @@ adminSettingsRouter.post(
adminSettingsRouter.get(
'/:key',
requirePermission(PERMISSIONS.SYSTEM_CONFIG_READ),
validate(systemSettingKeyParamSchema, 'params'),
systemSettingController.findByKey,
);
adminSettingsRouter.patch(
'/:key',
requirePermission(PERMISSIONS.SYSTEM_CONFIG_UPDATE),
validate(systemSettingKeyParamSchema, 'params'),
validate(updateSystemSettingSchema),
systemSettingController.updateSetting,
);
......
......@@ -26,3 +26,7 @@ export const updateMaintenanceModeSchema = z.object({
startAt: 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 { 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 { validate } from '../../middlewares/validate.middleware';
import { TransferController } from './transfer.controller';
......@@ -13,7 +14,8 @@ import {
const router = Router();
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.post('/', requirePermission(PERMISSIONS.TRANSFER_CREATE), validate(createTransferSchema), controller.create);
......
......@@ -15,8 +15,17 @@ const amountSchema = z
const dateSchema = z
.string()
.datetime({ offset: true, message: 'Date must be a valid ISO 8601 date-time' })
.transform((value) => new Date(value));
.trim()
.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({
id: z.string().uuid('Invalid transfer id'),
......
......@@ -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({
where: {
userId,
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
take: limit,
skip,
});
}
......@@ -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({
where: { webhookEndpointId: endpointId },
orderBy: { createdAt: 'desc' },
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', () => {
describe('Invalid Zalo Token', () => {
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)
.post('/api/v1/auth/zalo-login')
.send({
......@@ -67,6 +72,8 @@ describe('Zalo Auth Integration Tests', () => {
phoneNumber: testPhone,
});
mockFetchZalo.mockRestore();
expect(res.status).toBe(401);
expect(res.body.success).toBe(false);
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