Commit 80763c02 authored by ThinhNC's avatar ThinhNC

feat(admin-rbac): implement user management, audit remediation, distributed...

feat(admin-rbac): implement user management, audit remediation, distributed locking, and expanded rbac test suite
parent dfe66e09
...@@ -6,6 +6,7 @@ export default tseslint.config( ...@@ -6,6 +6,7 @@ export default tseslint.config(
ignores: [ ignores: [
'dist/**/*', 'dist/**/*',
'node_modules/**/*', 'node_modules/**/*',
'.wrangler/**/*',
'scripts/**/*', 'scripts/**/*',
'prisma/**/*', 'prisma/**/*',
'eslint.config.mjs', 'eslint.config.mjs',
......
...@@ -14,6 +14,7 @@ const config: Config = { ...@@ -14,6 +14,7 @@ const config: Config = {
clearMocks: true, clearMocks: true,
resetMocks: true, resetMocks: true,
restoreMocks: true, restoreMocks: true,
testTimeout: 30000,
}; };
export default config; export default config;
...@@ -281,6 +281,8 @@ model Transaction { ...@@ -281,6 +281,8 @@ model Transaction {
@@index([date]) @@index([date])
@@index([userId, date]) @@index([userId, date])
@@index([userId, type]) @@index([userId, type])
@@index([walletId, date])
@@index([categoryId, date])
@@map("transactions") @@map("transactions")
} }
......
...@@ -7,6 +7,7 @@ const businessDate = (value: string) => new Date(`${value}T00:00:00.000Z`); ...@@ -7,6 +7,7 @@ const businessDate = (value: string) => new Date(`${value}T00:00:00.000Z`);
async function main() { async function main() {
// 1. Seed Roles // 1. Seed Roles
const roles = [ const roles = [
{ name: 'SUPER_ADMIN', description: 'Siêu quản trị viên hệ thống — toàn quyền', isSystem: true },
{ name: 'ADMIN', description: 'Quản trị viên toàn quyền hệ thống', isSystem: true }, { name: 'ADMIN', description: 'Quản trị viên toàn quyền hệ thống', isSystem: true },
{ name: 'MANAGER', description: 'Quản lý vận hành và giám sát', isSystem: true }, { name: 'MANAGER', description: 'Quản lý vận hành và giám sát', isSystem: true },
{ name: 'USER', description: 'Người dùng tiêu chuẩn', isSystem: true }, { name: 'USER', description: 'Người dùng tiêu chuẩn', isSystem: true },
...@@ -107,6 +108,8 @@ async function main() { ...@@ -107,6 +108,8 @@ async function main() {
{ name: 'AI_ASSISTANT_USE', resource: 'AI_ASSISTANT', action: 'USE', description: 'Sử dụng trợ lý tài chính AI Gemini', isSystem: true }, { name: 'AI_ASSISTANT_USE', resource: 'AI_ASSISTANT', action: 'USE', description: 'Sử dụng trợ lý tài chính AI Gemini', isSystem: true },
// UPLOAD // UPLOAD
{ name: 'UPLOAD_FILE', resource: 'UPLOAD', action: 'CREATE', description: 'Tải lên hóa đơn và ảnh đại diện', isSystem: true }, { name: 'UPLOAD_FILE', resource: 'UPLOAD', action: 'CREATE', description: 'Tải lên hóa đơn và ảnh đại diện', isSystem: true },
// USER_RESTORE
{ name: 'USER_RESTORE', resource: 'USER', action: 'RESTORE', description: 'Khôi phục tài khoản người dùng đã bị xóa mềm', isSystem: true },
]; ];
const permissionMap: Record<string, string> = {}; const permissionMap: Record<string, string> = {};
...@@ -126,6 +129,23 @@ async function main() { ...@@ -126,6 +129,23 @@ async function main() {
console.log(`Upserted ${Object.keys(permissionMap).length} permissions`); console.log(`Upserted ${Object.keys(permissionMap).length} permissions`);
// 3. Assign Permissions to Roles // 3. Assign Permissions to Roles
// SUPER_ADMIN gets ALL permissions
for (const permId of Object.values(permissionMap)) {
await prisma.rolePermission.upsert({
where: {
roleId_permissionId: {
roleId: roleMap['SUPER_ADMIN'],
permissionId: permId,
},
},
update: {},
create: {
roleId: roleMap['SUPER_ADMIN'],
permissionId: permId,
},
});
}
// ADMIN gets ALL permissions // ADMIN gets ALL permissions
for (const permId of Object.values(permissionMap)) { for (const permId of Object.values(permissionMap)) {
await prisma.rolePermission.upsert({ await prisma.rolePermission.upsert({
...@@ -207,6 +227,9 @@ async function main() { ...@@ -207,6 +227,9 @@ async function main() {
} }
} }
const superAdminEmail = 'superadmin@finwise.local';
const superAdminPassword = await bcrypt.hash('SuperAdmin@123456', 10);
const adminEmail = 'admin@finwise.local'; const adminEmail = 'admin@finwise.local';
const adminPassword = await bcrypt.hash('Admin@123456', 10); const adminPassword = await bcrypt.hash('Admin@123456', 10);
...@@ -216,6 +239,23 @@ async function main() { ...@@ -216,6 +239,23 @@ async function main() {
const userEmail = 'user@finwise.local'; const userEmail = 'user@finwise.local';
const userPassword = await bcrypt.hash('User@123456', 10); const userPassword = await bcrypt.hash('User@123456', 10);
await prisma.user.upsert({
where: { email: superAdminEmail },
update: {
password: superAdminPassword,
fullName: 'Super Admin',
roleId: roleMap['SUPER_ADMIN'],
isActive: true,
},
create: {
email: superAdminEmail,
password: superAdminPassword,
fullName: 'Super Admin',
roleId: roleMap['SUPER_ADMIN'],
isActive: true,
},
});
await prisma.user.upsert({ await prisma.user.upsert({
where: { email: adminEmail }, where: { email: adminEmail },
update: { update: {
......
...@@ -19,7 +19,21 @@ app.use(helmet({ contentSecurityPolicy: false, }),); ...@@ -19,7 +19,21 @@ app.use(helmet({ contentSecurityPolicy: false, }),);
const corsOptions: cors.CorsOptions = { const corsOptions: cors.CorsOptions = {
origin: (origin, callback) => { origin: (origin, callback) => {
const allowed = envConfig.cors.allowedOrigins; const allowed = envConfig.cors.allowedOrigins;
if (allowed.includes('*') || !origin || allowed.includes(origin)) { // Allow non-browser requests without origin header (e.g., mobile apps, cURL, server-to-server)
if (!origin) {
callback(null, true);
return;
}
// Disallow wildcard with credentials in production
if (allowed.includes('*')) {
if (envConfig.nodeEnv === 'production') {
callback(new Error('CORS wildcard origin not allowed with credentials in production'), false);
return;
}
callback(null, true);
return;
}
if (allowed.includes(origin)) {
callback(null, true); callback(null, true);
} else { } else {
callback(null, false); callback(null, false);
......
...@@ -4,6 +4,7 @@ export const PERMISSIONS = { ...@@ -4,6 +4,7 @@ export const PERMISSIONS = {
USER_CREATE: 'USER_CREATE', USER_CREATE: 'USER_CREATE',
USER_UPDATE: 'USER_UPDATE', USER_UPDATE: 'USER_UPDATE',
USER_DELETE: 'USER_DELETE', USER_DELETE: 'USER_DELETE',
USER_RESTORE: 'USER_RESTORE',
// ROLE // ROLE
ROLE_READ: 'ROLE_READ', ROLE_READ: 'ROLE_READ',
...@@ -83,6 +84,7 @@ export const PERMISSIONS = { ...@@ -83,6 +84,7 @@ export const PERMISSIONS = {
// NOTIFICATION // NOTIFICATION
NOTIFICATION_READ: 'NOTIFICATION_READ', NOTIFICATION_READ: 'NOTIFICATION_READ',
NOTIFICATION_UPDATE: 'NOTIFICATION_UPDATE', NOTIFICATION_UPDATE: 'NOTIFICATION_UPDATE',
NOTIFICATION_DELETE: 'NOTIFICATION_DELETE',
// REMINDER // REMINDER
REMINDER_READ: 'REMINDER_READ', REMINDER_READ: 'REMINDER_READ',
......
...@@ -2,6 +2,7 @@ export const SYSTEM_ROLES = { ...@@ -2,6 +2,7 @@ export const SYSTEM_ROLES = {
ADMIN: 'ADMIN', ADMIN: 'ADMIN',
USER: 'USER', USER: 'USER',
MANAGER: 'MANAGER', MANAGER: 'MANAGER',
SUPER_ADMIN: 'SUPER_ADMIN',
} as const; } as const;
export type SystemRole = (typeof SYSTEM_ROLES)[keyof typeof SYSTEM_ROLES]; export type SystemRole = (typeof SYSTEM_ROLES)[keyof typeof SYSTEM_ROLES];
...@@ -2,6 +2,7 @@ import { cacheService } from './cache.service'; ...@@ -2,6 +2,7 @@ import { cacheService } from './cache.service';
import { LoggerService } from './logger.service'; import { LoggerService } from './logger.service';
interface LocalLock { interface LocalLock {
token: string;
expiresAt: number; expiresAt: number;
} }
...@@ -10,19 +11,21 @@ export class LockService { ...@@ -10,19 +11,21 @@ export class LockService {
private readonly localLocks = new Map<string, LocalLock>(); private readonly localLocks = new Map<string, LocalLock>();
/** /**
* Acquires a lock. * Acquires a lock with an owner token.
* @param lockKey Key of the lock (e.g. "finwise:lock:notification-worker") * @param lockKey Key of the lock (e.g. "finwise:lock:notification-worker")
* @param ttlMs Time-to-live for the lock in milliseconds * @param ttlMs Time-to-live for the lock in milliseconds
* @returns true if lock was acquired successfully, false otherwise * @param customToken Optional custom token (a random UUID is generated if omitted)
* @returns lock token string if acquired successfully, null otherwise
*/ */
async acquire(lockKey: string, ttlMs: number): Promise<boolean> { async acquire(lockKey: string, ttlMs: number, customToken?: string): Promise<string | null> {
const token = customToken || crypto.randomUUID();
const isUsingRedis = cacheService.isUsingRedis(); const isUsingRedis = cacheService.isUsingRedis();
const redisClient = cacheService.getRedisClient(); const redisClient = cacheService.getRedisClient();
if (isUsingRedis && redisClient) { if (isUsingRedis && redisClient) {
try { try {
const result = await redisClient.set(lockKey, 'locked', 'PX', ttlMs, 'NX'); const result = await redisClient.set(lockKey, token, 'PX', ttlMs, 'NX');
return result === 'OK'; return result === 'OK' ? token : null;
} catch (error) { } catch (error) {
this.logger.error(`Redis error acquiring lock for key "${lockKey}":`, error); this.logger.error(`Redis error acquiring lock for key "${lockKey}":`, error);
// Fallback to local lock simulation // Fallback to local lock simulation
...@@ -35,32 +38,55 @@ export class LockService { ...@@ -35,32 +38,55 @@ export class LockService {
if (existing && now < existing.expiresAt) { if (existing && now < existing.expiresAt) {
// Lock is still active/held // Lock is still active/held
return false; return null;
} }
// Set lock // Set lock
this.localLocks.set(lockKey, { expiresAt: now + ttlMs }); this.localLocks.set(lockKey, { token, expiresAt: now + ttlMs });
return true; return token;
} }
/** /**
* Releases a lock. * Releases a lock safely only if the token matches the owner.
* @param lockKey Key of the lock * @param lockKey Key of the lock
* @param lockToken Token received when acquiring the lock
*/ */
async release(lockKey: string): Promise<void> { async release(lockKey: string, lockToken?: string): Promise<boolean> {
const isUsingRedis = cacheService.isUsingRedis(); const isUsingRedis = cacheService.isUsingRedis();
const redisClient = cacheService.getRedisClient(); const redisClient = cacheService.getRedisClient();
if (isUsingRedis && redisClient) { if (isUsingRedis && redisClient) {
try { try {
if (lockToken) {
const luaScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await redisClient.eval(luaScript, 1, lockKey, lockToken);
return result === 1;
}
await redisClient.del(lockKey); await redisClient.del(lockKey);
return; return true;
} catch (error) { } catch (error) {
this.logger.error(`Redis error releasing lock for key "${lockKey}":`, error); this.logger.error(`Redis error releasing lock for key "${lockKey}":`, error);
return false;
} }
} }
const existing = this.localLocks.get(lockKey);
if (!existing) {
return false;
}
if (lockToken && existing.token !== lockToken) {
return false;
}
this.localLocks.delete(lockKey); this.localLocks.delete(lockKey);
return true;
} }
} }
......
...@@ -21,6 +21,9 @@ export function createHyperdrivePrismaClient( ...@@ -21,6 +21,9 @@ export function createHyperdrivePrismaClient(
const pool = new Pool({ const pool = new Pool({
connectionString, connectionString,
max: 5,
idleTimeoutMillis: 10000,
connectionTimeoutMillis: 10000,
}); });
const adapter = new PrismaPg(pool); const adapter = new PrismaPg(pool);
...@@ -30,18 +33,12 @@ export function createHyperdrivePrismaClient( ...@@ -30,18 +33,12 @@ export function createHyperdrivePrismaClient(
}); });
} }
// Global cached client instance cho Worker isolates // Connection-keyed cached client instances
let cachedWorkerPrisma: PrismaClient | null = null; const clientPool = new Map<string, PrismaClient>();
let defaultNodePrisma: PrismaClient | null = null; let defaultNodePrisma: PrismaClient | null = null;
let currentWorkerEnv: WorkerEnv | null = null;
export function setWorkerEnv(env: WorkerEnv) { export function setWorkerEnv(_env: WorkerEnv) {
currentWorkerEnv = env; // Retained for backward-compatibility without global state mutation
if (env?.HYPERDRIVE?.connectionString) {
if (!cachedWorkerPrisma) {
cachedWorkerPrisma = createHyperdrivePrismaClient(env.HYPERDRIVE);
}
}
} }
function isEdgeRuntime(): boolean { function isEdgeRuntime(): boolean {
...@@ -56,29 +53,38 @@ function isEdgeRuntime(): boolean { ...@@ -56,29 +53,38 @@ function isEdgeRuntime(): boolean {
* Lấy PrismaClient tương thích với Cloudflare Worker Environment hoặc Node.js process * Lấy PrismaClient tương thích với Cloudflare Worker Environment hoặc Node.js process
*/ */
export function getPrismaClient(env?: WorkerEnv): PrismaClient { export function getPrismaClient(env?: WorkerEnv): PrismaClient {
const activeEnv = env || currentWorkerEnv; if (env?.HYPERDRIVE?.connectionString) {
if (activeEnv?.HYPERDRIVE?.connectionString) { const key = env.HYPERDRIVE.connectionString;
if (!cachedWorkerPrisma) { let client = clientPool.get(key);
cachedWorkerPrisma = createHyperdrivePrismaClient(activeEnv.HYPERDRIVE); if (!client) {
client = createHyperdrivePrismaClient(env.HYPERDRIVE);
clientPool.set(key, client);
} }
return cachedWorkerPrisma; return client;
} }
if (isEdgeRuntime()) { if (isEdgeRuntime()) {
if (!cachedWorkerPrisma) { const connStr =
const connStr = process.env.DATABASE_URL ||
process.env.DATABASE_URL || process.env.CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE ||
process.env.CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE || '';
''; let client = clientPool.get(connStr);
cachedWorkerPrisma = createHyperdrivePrismaClient(connStr); if (!client) {
client = createHyperdrivePrismaClient(connStr);
clientPool.set(connStr, client);
} }
return cachedWorkerPrisma; return client;
} }
if (!defaultNodePrisma) { if (!defaultNodePrisma) {
defaultNodePrisma = new PrismaClient({ const connStr = process.env.DATABASE_URL || '';
log: logOptions, if (connStr) {
}); defaultNodePrisma = createHyperdrivePrismaClient(connStr);
} else {
defaultNodePrisma = new PrismaClient({
log: logOptions,
});
}
} }
return defaultNodePrisma; return defaultNodePrisma;
} }
......
import { TransactionType } from '@prisma/client'; import { TransactionType } from '@prisma/client';
import { prisma } from '../../database/prisma.client'; import { prisma } from '../../database/prisma.client';
import { prismaDateToBusinessDate } from '../../common/date-time/business-time'; import {
addBusinessDays,
businessDateToPrismaDate,
instantToBusinessDate,
prismaDateToBusinessDate,
} from '../../common/date-time/business-time';
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
const MILLISECONDS_PER_MINUTE = 60 * 1000; const MILLISECONDS_PER_MINUTE = 60 * 1000;
export class AnomalyRepository { export class AnomalyRepository {
...@@ -12,7 +16,9 @@ export class AnomalyRepository { ...@@ -12,7 +16,9 @@ export class AnomalyRepository {
excludeTxnId?: string, excludeTxnId?: string,
days = 60, days = 60,
): Promise<number[]> { ): Promise<number[]> {
const since = new Date(Date.now() - days * MILLISECONDS_PER_DAY); const today = instantToBusinessDate(new Date());
const sinceBusinessDate = addBusinessDays(today, -days);
const since = businessDateToPrismaDate(sinceBusinessDate);
const txns = await prisma.transaction.findMany({ const txns = await prisma.transaction.findMany({
where: { where: {
...@@ -52,6 +58,56 @@ export class AnomalyRepository { ...@@ -52,6 +58,56 @@ export class AnomalyRepository {
}); });
} }
async getBatchCategoryHistories(
userId: string,
categoryIds: string[],
days = 60,
): Promise<Map<string, Array<{ id: string; amount: number }>>> {
const today = instantToBusinessDate(new Date());
const sinceBusinessDate = addBusinessDays(today, -days);
const since = businessDateToPrismaDate(sinceBusinessDate);
const txns = await prisma.transaction.findMany({
where: {
userId,
categoryId: { in: categoryIds },
type: TransactionType.EXPENSE,
date: { gte: since },
},
select: {
id: true,
categoryId: true,
amount: true,
},
orderBy: {
date: 'desc',
},
take: 2000,
});
const resultMap = new Map<string, Array<{ id: string; amount: number }>>();
for (const tx of txns) {
const list = resultMap.get(tx.categoryId) || [];
if (list.length < 200) {
list.push({ id: tx.id, amount: tx.amount.toNumber() });
resultMap.set(tx.categoryId, list);
}
}
return resultMap;
}
async getBatchWalletBalances(userId: string, walletIds: string[]): Promise<Map<string, number>> {
const wallets = await prisma.wallet.findMany({
where: { id: { in: walletIds }, userId },
select: { id: true, balance: true },
});
const map = new Map<string, number>();
for (const w of wallets) {
map.set(w.id, w.balance.toNumber());
}
return map;
}
async getWalletBalance(userId: string, walletId: string): Promise<number> { async getWalletBalance(userId: string, walletId: string): Promise<number> {
const wallet = await prisma.wallet.findFirst({ const wallet = await prisma.wallet.findFirst({
where: { id: walletId, userId }, where: { id: walletId, userId },
......
...@@ -45,16 +45,38 @@ export class AnomalyService { ...@@ -45,16 +45,38 @@ export class AnomalyService {
async getRecentAnomalies(userId: string): Promise<FlaggedAnomalyTransactionDto[]> { async getRecentAnomalies(userId: string): Promise<FlaggedAnomalyTransactionDto[]> {
const transactions = await this.repository.getRecentExpenseTransactions(userId, 40); const transactions = await this.repository.getRecentExpenseTransactions(userId, 40);
if (transactions.length === 0) {
return [];
}
const uniqueCategoryIds = [...new Set(transactions.map((t) => t.categoryId))];
const uniqueWalletIds = [...new Set(transactions.map((t) => t.walletId))];
const [categoryHistoryMap, walletBalanceMap] = await Promise.all([
this.repository.getBatchCategoryHistories(userId, uniqueCategoryIds),
this.repository.getBatchWalletBalances(userId, uniqueWalletIds),
]);
const flagged: FlaggedAnomalyTransactionDto[] = []; const flagged: FlaggedAnomalyTransactionDto[] = [];
for (const tx of transactions) { for (const tx of transactions) {
const evaluation = await this.evaluateTransaction(userId, { const amountNum = parseFloat(tx.amount);
transactionId: tx.id, const dateObj = tx.createdAt ? new Date(tx.createdAt) : new Date();
walletId: tx.walletId, const hourOfDayVietnam = (dateObj.getUTCHours() + 7) % 24;
categoryId: tx.categoryId,
amount: tx.amount, const historyList = categoryHistoryMap.get(tx.categoryId) || [];
type: 'EXPENSE', const historicalCategoryAmounts = historyList
occurredAt: tx.createdAt, .filter((h) => h.id !== tx.id)
.map((h) => h.amount);
const walletBalance = walletBalanceMap.get(tx.walletId) ?? 0;
const evaluation = AnomalyMathEngine.evaluate({
amount: amountNum,
historicalCategoryAmounts,
recentWalletTxnCount: 1, // baseline within batch
walletBalance,
hourOfDayVietnam,
}); });
if (evaluation.isAnomaly) { if (evaluation.isAnomaly) {
......
...@@ -18,9 +18,7 @@ export const registerSchema = z.object({ ...@@ -18,9 +18,7 @@ export const registerSchema = z.object({
.string() .string()
.min(1, 'Email is required') .min(1, 'Email is required')
.email('Invalid email format') .email('Invalid email format')
.refine((val) => val.endsWith('@gmail.com'), { .transform((val) => val.trim().toLowerCase()),
message: 'Only @gmail.com emails are allowed',
}),
password: z password: z
.string() .string()
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
...@@ -46,7 +44,13 @@ export const updateProfileSchema = z.object({ ...@@ -46,7 +44,13 @@ export const updateProfileSchema = z.object({
.optional(), .optional(),
avatarPositionX: z.number().int().min(0).max(100).optional(), avatarPositionX: z.number().int().min(0).max(100).optional(),
avatarPositionY: z.number().int().min(0).max(100).optional(), avatarPositionY: z.number().int().min(0).max(100).optional(),
phoneNumber: z.string().regex(/^[0-9]{10,11}$/, 'Invalid phone number format (must be 10-11 digits)').optional(), phoneNumber: z
.union([
z.string().regex(/^[0-9]{10,11}$/, 'Invalid phone number format (must be 10-11 digits)'),
z.literal('').transform(() => null),
z.null(),
])
.optional(),
}); });
export const updatePasswordSchema = z.object({ export const updatePasswordSchema = z.object({
......
...@@ -198,6 +198,7 @@ export class BudgetRepository { ...@@ -198,6 +198,7 @@ export class BudgetRepository {
date: true, date: true,
wallet: { select: { currency: true } }, wallet: { select: { currency: true } },
}, },
take: 10000,
}); });
for (const budget of budgets) { for (const budget of budgets) {
......
...@@ -126,45 +126,45 @@ export class ForecastRepository { ...@@ -126,45 +126,45 @@ export class ForecastRepository {
return []; return [];
} }
const results: BudgetForecastInput[] = []; const results: BudgetForecastInput[] = await Promise.all(
budgets.map(async (budget) => {
const startInstant = budget.startDate;
const endBusinessDate = prismaDateToBusinessDate(budget.endDate);
const nextDay = businessWallTimeToInstant(
instantToBusinessDate(new Date(budget.endDate.getTime() + 24 * 60 * 60 * 1000)),
);
for (const budget of budgets) { const aggregate = await prisma.transaction.aggregate({
const startInstant = budget.startDate; where: {
const endBusinessDate = prismaDateToBusinessDate(budget.endDate); userId,
const nextDay = businessWallTimeToInstant( type: TransactionType.EXPENSE,
instantToBusinessDate(new Date(budget.endDate.getTime() + 24 * 60 * 60 * 1000)), wallet: {
); currency: budget.currency,
},
const aggregate = await prisma.transaction.aggregate({ date: {
where: { gte: startInstant,
userId, lt: nextDay,
type: TransactionType.EXPENSE, },
wallet: { ...(budget.categoryId ? { categoryId: budget.categoryId } : {}),
currency: budget.currency,
}, },
date: { _sum: {
gte: startInstant, amount: true,
lt: nextDay,
}, },
...(budget.categoryId ? { categoryId: budget.categoryId } : {}), });
},
_sum: {
amount: true,
},
});
results.push({ return {
id: budget.id, id: budget.id,
name: budget.name, name: budget.name,
categoryName: budget.category?.name ?? null, categoryName: budget.category?.name ?? null,
currency: budget.currency, currency: budget.currency,
amount: budget.amount, amount: budget.amount,
spentAmount: aggregate._sum.amount ?? new Prisma.Decimal(0), spentAmount: aggregate._sum.amount ?? new Prisma.Decimal(0),
startDate: prismaDateToBusinessDate(budget.startDate) as `${number}-${number}-${number}`, startDate: prismaDateToBusinessDate(budget.startDate) as `${number}-${number}-${number}`,
endDate: endBusinessDate as `${number}-${number}-${number}`, endDate: endBusinessDate as `${number}-${number}-${number}`,
alertThreshold: budget.alertThreshold, alertThreshold: budget.alertThreshold,
}); };
} }),
);
return results; return results;
} }
......
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 { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware'; import {
requireAnyPermission,
requirePermission,
} from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware'; import { validate } from '../../middlewares/validate.middleware';
import { NotificationController } from './notification.controller'; import { NotificationController } from './notification.controller';
import { import {
...@@ -31,6 +34,11 @@ router.patch( ...@@ -31,6 +34,11 @@ router.patch(
validate(notificationParamsSchema, 'params'), validate(notificationParamsSchema, 'params'),
controller.markRead, controller.markRead,
); );
router.delete('/:id', requirePermission(PERMISSIONS.NOTIFICATION_UPDATE), validate(notificationParamsSchema, 'params'), controller.remove); router.delete(
'/:id',
requireAnyPermission(PERMISSIONS.NOTIFICATION_DELETE, PERMISSIONS.NOTIFICATION_UPDATE),
validate(notificationParamsSchema, 'params'),
controller.remove,
);
export default router; export default router;
...@@ -41,9 +41,9 @@ export class NotificationWorker { ...@@ -41,9 +41,9 @@ export class NotificationWorker {
const lockKey = 'finwise:lock:notification-worker'; const lockKey = 'finwise:lock:notification-worker';
const lockTtlMs = 5 * 60 * 1000; // 5 minutes max lock duration const lockTtlMs = 5 * 60 * 1000; // 5 minutes max lock duration
const lockAcquired = await lockService.acquire(lockKey, lockTtlMs); const lockToken = await lockService.acquire(lockKey, lockTtlMs);
if (!lockAcquired) { if (!lockToken) {
return; return;
} }
...@@ -85,7 +85,7 @@ export class NotificationWorker { ...@@ -85,7 +85,7 @@ export class NotificationWorker {
} }
} finally { } finally {
this.running = false; this.running = false;
await lockService.release(lockKey); await lockService.release(lockKey, lockToken);
} }
} }
} }
......
...@@ -225,9 +225,20 @@ export class QueryCompiler { ...@@ -225,9 +225,20 @@ export class QueryCompiler {
? `ví ${ast.walletNames.join(', ')}` ? `ví ${ast.walletNames.join(', ')}`
: ''; : '';
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;
}
}
const summary = count === 0 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}.` ? `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')} VND qua ${count} giao dịch (bình quân: ${Math.round(avgVal).toLocaleString('vi-VN')} VND/giao dịch).`; : `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,
...@@ -238,7 +249,7 @@ export class QueryCompiler { ...@@ -238,7 +249,7 @@ export class QueryCompiler {
average: avgVal.toFixed(2), average: avgVal.toFixed(2),
minValue: minVal !== null ? minVal.toFixed(2) : null, minValue: minVal !== null ? minVal.toFixed(2) : null,
maxValue: maxVal !== null ? maxVal.toFixed(2) : null, maxValue: maxVal !== null ? maxVal.toFixed(2) : null,
currency: 'VND', currency: resolvedCurrency,
groups, groups,
items, items,
}; };
......
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { import {
ExecuteQueryResultDto, ExecuteQueryResultDto,
ParseQueryResponseDto, ParseQueryResponseDto,
...@@ -52,7 +54,7 @@ export class QueryService { ...@@ -52,7 +54,7 @@ export class QueryService {
} }
if (!ast) { if (!ast) {
throw new Error('No query or AST provided for execution'); throw new AppError('No query or AST provided for execution', 400, ERROR_CODE.VALIDATION_ERROR);
} }
return QueryCompiler.execute(userId, ast); return QueryCompiler.execute(userId, ast);
......
...@@ -33,6 +33,8 @@ export interface AuditLogQueryDto { ...@@ -33,6 +33,8 @@ export interface AuditLogQueryDto {
action?: string; action?: string;
targetType?: string; targetType?: string;
targetId?: string; targetId?: string;
dateFrom?: string;
dateTo?: string;
page?: number; page?: number;
limit?: number; limit?: number;
sortBy?: string; sortBy?: string;
......
...@@ -326,13 +326,21 @@ export class RbacRepository { ...@@ -326,13 +326,21 @@ export class RbacRepository {
} }
async findAllAuditLogs(query: AuditLogQueryDto) { async findAllAuditLogs(query: AuditLogQueryDto) {
const { actorId, action, targetType, targetId, page = 1, limit = 20, sortBy = 'createdAt', order = 'desc' } = query; const { actorId, action, targetType, targetId, dateFrom, dateTo, page = 1, limit = 20, sortBy = 'createdAt', order = 'desc' } = query;
const where: Prisma.AuditLogWhereInput = { const where: Prisma.AuditLogWhereInput = {
...(actorId ? { actorId } : {}), ...(actorId ? { actorId } : {}),
...(action ? { action } : {}), ...(action ? { action } : {}),
...(targetType ? { targetType } : {}), ...(targetType ? { targetType } : {}),
...(targetId ? { targetId } : {}), ...(targetId ? { targetId } : {}),
...(dateFrom || dateTo
? {
createdAt: {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
...(dateTo ? { lte: new Date(dateTo) } : {}),
},
}
: {}),
}; };
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
...@@ -347,8 +355,53 @@ export class RbacRepository { ...@@ -347,8 +355,53 @@ export class RbacRepository {
prisma.auditLog.count({ where }), prisma.auditLog.count({ where }),
]); ]);
const actorIds = data.flatMap((log) => (log.actorId ? [log.actorId] : []));
const userTargetIds = data.flatMap((log) =>
log.targetType === 'USER' && log.targetId ? [log.targetId] : []
);
const roleTargetIds = data.flatMap((log) =>
log.targetType === 'ROLE' && log.targetId ? [log.targetId] : []
);
const permissionTargetIds = data.flatMap((log) =>
log.targetType === 'PERMISSION' && log.targetId ? [log.targetId] : []
);
const [users, roles, permissions] = await Promise.all([
prisma.user.findMany({
where: { id: { in: [...new Set([...actorIds, ...userTargetIds])] } },
select: { id: true, email: true },
}),
prisma.role.findMany({
where: { id: { in: [...new Set(roleTargetIds)] } },
select: { id: true, name: true },
}),
prisma.permission.findMany({
where: { id: { in: [...new Set(permissionTargetIds)] } },
select: { id: true, name: true },
}),
]);
const userEmailById = new Map(users.map((user) => [user.id, user.email]));
const roleNameById = new Map(roles.map((role) => [role.id, role.name]));
const permissionNameById = new Map(permissions.map((permission) => [permission.id, permission.name]));
const enrichedData = data.map((log) => {
let targetLabel: string | null = null;
if (log.targetId) {
if (log.targetType === 'USER') targetLabel = userEmailById.get(log.targetId) ?? null;
if (log.targetType === 'ROLE') targetLabel = roleNameById.get(log.targetId) ?? null;
if (log.targetType === 'PERMISSION') targetLabel = permissionNameById.get(log.targetId) ?? null;
}
return {
...log,
actorEmail: log.actorId ? userEmailById.get(log.actorId) ?? null : null,
targetLabel,
};
});
return { return {
data, data: enrichedData,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
}; };
} }
......
...@@ -80,9 +80,14 @@ export class RbacService { ...@@ -80,9 +80,14 @@ export class RbacService {
) { ) {
const role = await this.findRoleById(id); const role = await this.findRoleById(id);
// Protected: cannot rename ADMIN system role // Protected: cannot rename ADMIN or SUPER_ADMIN system roles
if (role.isSystem && role.name === SYSTEM_ROLES.ADMIN && data.name && data.name !== SYSTEM_ROLES.ADMIN) { if (
throw new AppError('Không thể đổi tên vai trò quản trị hệ thống ADMIN', 400, ERROR_CODE.ROLE_SYSTEM_PROTECTED); role.isSystem &&
[SYSTEM_ROLES.ADMIN, SYSTEM_ROLES.SUPER_ADMIN].includes(role.name as any) &&
data.name &&
data.name !== role.name
) {
throw new AppError(`Không thể đổi tên vai trò hệ thống "${role.name}"`, 400, ERROR_CODE.ROLE_SYSTEM_PROTECTED);
} }
if (data.name && data.name !== role.name) { if (data.name && data.name !== role.name) {
......
...@@ -56,6 +56,8 @@ export const auditLogQuerySchema = z.object({ ...@@ -56,6 +56,8 @@ export const auditLogQuerySchema = z.object({
action: z.string().optional(), action: z.string().optional(),
targetType: z.string().optional(), targetType: z.string().optional(),
targetId: z.string().optional(), targetId: z.string().optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
page: z.coerce.number().int().min(1).default(1), page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20), limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['createdAt']).default('createdAt'), sortBy: z.enum(['createdAt']).default('createdAt'),
......
...@@ -97,10 +97,40 @@ export class TransferService { ...@@ -97,10 +97,40 @@ export class TransferService {
current.destinationWalletId, current.destinationWalletId,
transaction, transaction,
); );
if (sourceWallet.isArchived || destinationWallet.isArchived) {
throw new AppError(
'Archived wallets cannot be modified',
409,
ERROR_CODE.WALLET_ARCHIVED,
);
}
const amount = new Prisma.Decimal(current.amount); const amount = new Prisma.Decimal(current.amount);
if (destinationWallet.balance.lessThan(amount)) {
throw new AppError(
'Destination wallet has insufficient balance to reverse transfer',
409,
ERROR_CODE.INSUFFICIENT_BALANCE,
);
}
const debitResult = await this.repository.debitWallet(
userId,
destinationWallet.id,
amount,
transaction,
);
if (debitResult.count !== 1) {
throw new AppError(
'Destination wallet has insufficient balance to reverse transfer',
409,
ERROR_CODE.INSUFFICIENT_BALANCE,
);
}
await this.repository.incrementWallet(sourceWallet.id, amount, transaction); await this.repository.incrementWallet(sourceWallet.id, amount, transaction);
await this.repository.decrementWallet(destinationWallet.id, amount, transaction);
await this.repository.delete(id, transaction); await this.repository.delete(id, transaction);
return current; return current;
......
...@@ -46,7 +46,12 @@ export class UserController { ...@@ -46,7 +46,12 @@ export class UserController {
update = async (req: Request, res: Response, next: NextFunction) => { update = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const body = req.body as UpdateUserDto; const body = req.body as UpdateUserDto;
const result = await this.service.update(req.params.id, body); const actorId = req.user?.id;
const metadata = {
ipAddress: req.ip || (req.headers['x-forwarded-for'] as string),
userAgent: req.headers['user-agent'],
};
const result = await this.service.update(req.params.id, body, actorId, metadata);
res.json({ res.json({
success: true, success: true,
...@@ -61,7 +66,11 @@ export class UserController { ...@@ -61,7 +66,11 @@ export class UserController {
try { try {
const { id } = req.params; const { id } = req.params;
const adminId = req.user.id; const adminId = req.user.id;
await this.service.softDelete(id, adminId); const metadata = {
ipAddress: req.ip || (req.headers['x-forwarded-for'] as string),
userAgent: req.headers['user-agent'],
};
await this.service.softDelete(id, adminId, metadata);
res.json({ res.json({
success: true, success: true,
...@@ -71,4 +80,37 @@ export class UserController { ...@@ -71,4 +80,37 @@ export class UserController {
next(error); next(error);
} }
}; };
restore = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const actorId = req.user.id;
const metadata = {
ipAddress: req.ip || (req.headers['x-forwarded-for'] as string),
userAgent: req.headers['user-agent'],
};
const result = await this.service.restore(id, actorId, metadata);
res.json({
success: true,
data: result,
message: 'Người dùng đã được khôi phục thành công',
});
} catch (error) {
next(error);
}
};
getAdminStats = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.getAdminStats();
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
} }
...@@ -32,3 +32,11 @@ export interface UserResponseDto { ...@@ -32,3 +32,11 @@ export interface UserResponseDto {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
export interface AdminStatsDto {
totalUsers: number;
activeUsers: number;
inactiveUsers: number;
deletedUsers: number;
totalRoles: number;
}
...@@ -49,6 +49,13 @@ export class UserRepository { ...@@ -49,6 +49,13 @@ export class UserRepository {
}); });
} }
findDeletedById(id: string) {
return prisma.user.findFirst({
where: { id, deletedAt: { not: null } },
include: { role: true },
});
}
findByEmail(email: string) { findByEmail(email: string) {
return prisma.user.findFirst({ return prisma.user.findFirst({
where: { email, deletedAt: null }, where: { email, deletedAt: null },
...@@ -102,4 +109,34 @@ export class UserRepository { ...@@ -102,4 +109,34 @@ export class UserRepository {
}), }),
]); ]);
} }
async restore(id: string) {
return prisma.user.update({
where: { id },
data: {
deletedAt: null,
deletedBy: null,
isActive: true,
},
include: { role: true },
});
}
async getAdminStats() {
const [totalUsers, activeUsers, inactiveUsers, deletedUsers, totalRoles] = await prisma.$transaction([
prisma.user.count({ where: { deletedAt: null } }),
prisma.user.count({ where: { deletedAt: null, isActive: true } }),
prisma.user.count({ where: { deletedAt: null, isActive: false } }),
prisma.user.count({ where: { deletedAt: { not: null } } }),
prisma.role.count(),
]);
return {
totalUsers,
activeUsers,
inactiveUsers,
deletedUsers,
totalRoles,
};
}
} }
...@@ -11,9 +11,11 @@ const controller = new UserController(); ...@@ -11,9 +11,11 @@ const controller = new UserController();
// GET /users?email=...&fullName=...&roleName=...&isActive=...&sortBy=...&order=...&page=...&limit=... // GET /users?email=...&fullName=...&roleName=...&isActive=...&sortBy=...&order=...&page=...&limit=...
router.get('/', authMiddleware, requirePermission(PERMISSIONS.USER_READ), validate(findAllUserSchema, 'query'), controller.findAll); router.get('/', authMiddleware, requirePermission(PERMISSIONS.USER_READ), validate(findAllUserSchema, 'query'), controller.findAll);
router.get('/admin/stats', authMiddleware, requirePermission(PERMISSIONS.USER_READ), controller.getAdminStats);
router.get('/:id', authMiddleware, requirePermission(PERMISSIONS.USER_READ), validate(userParamsSchema, 'params'), controller.findById); router.get('/:id', authMiddleware, requirePermission(PERMISSIONS.USER_READ), validate(userParamsSchema, 'params'), controller.findById);
router.post('/', authMiddleware, requirePermission(PERMISSIONS.USER_CREATE), validate(createUserSchema), controller.create); router.post('/', authMiddleware, requirePermission(PERMISSIONS.USER_CREATE), validate(createUserSchema), controller.create);
router.put('/:id', authMiddleware, requirePermission(PERMISSIONS.USER_UPDATE), validate(userParamsSchema, 'params'), validate(updateUserSchema), controller.update); router.put('/:id', authMiddleware, requirePermission(PERMISSIONS.USER_UPDATE), validate(userParamsSchema, 'params'), validate(updateUserSchema), controller.update);
router.delete('/:id', authMiddleware, requirePermission(PERMISSIONS.USER_DELETE), validate(userParamsSchema, 'params'), controller.softDelete); router.delete('/:id', authMiddleware, requirePermission(PERMISSIONS.USER_DELETE), validate(userParamsSchema, 'params'), controller.softDelete);
router.post('/:id/restore', authMiddleware, requirePermission(PERMISSIONS.USER_RESTORE), validate(userParamsSchema, 'params'), controller.restore);
export default router; export default router;
...@@ -41,12 +41,17 @@ export class UserService { ...@@ -41,12 +41,17 @@ export class UserService {
}); });
} }
async update(id: string, data: UpdateUserDto) { async update(
id: string,
data: UpdateUserDto,
actorId?: string,
metadata?: { ipAddress?: string; userAgent?: string }
) {
const user = await this.findById(id); const user = await this.findById(id);
// If role is changing, delegate to rbacService for safety checks // If role is changing, delegate to rbacService for safety checks
if (data.roleId && data.roleId !== user.roleId) { if (data.roleId && data.roleId !== user.roleId) {
await rbacService.updateUserRole(id, data.roleId); await rbacService.updateUserRole(id, data.roleId, actorId, metadata);
} }
// If deactivating user, check last admin protection // If deactivating user, check last admin protection
...@@ -67,10 +72,29 @@ export class UserService { ...@@ -67,10 +72,29 @@ export class UserService {
}); });
await rbacService.invalidateUserCache(id); await rbacService.invalidateUserCache(id);
// Audit log for status change
if (data.isActive !== undefined && data.isActive !== user.isActive) {
await this.rbacRepository.createAuditLog({
actorId,
action: data.isActive ? 'USER_ACTIVATE' : 'USER_DEACTIVATE',
targetType: 'USER',
targetId: id,
previousState: { isActive: user.isActive },
newState: { isActive: data.isActive },
ipAddress: metadata?.ipAddress,
userAgent: metadata?.userAgent,
});
}
return updated; return updated;
} }
async softDelete(id: string, adminId: string) { async softDelete(
id: string,
adminId: string,
metadata?: { ipAddress?: string; userAgent?: string }
) {
const user = await this.findById(id); const user = await this.findById(id);
const userRBAC = await this.rbacRepository.getUserRoleAndPermissions(id); const userRBAC = await this.rbacRepository.getUserRoleAndPermissions(id);
...@@ -84,6 +108,55 @@ export class UserService { ...@@ -84,6 +108,55 @@ export class UserService {
const result = await this.repository.softDelete(id, adminId); const result = await this.repository.softDelete(id, adminId);
await rbacService.invalidateUserCache(id); await rbacService.invalidateUserCache(id);
await this.rbacRepository.createAuditLog({
actorId: adminId,
action: 'USER_DELETE',
targetType: 'USER',
targetId: id,
previousState: { email: user.email, fullName: user.fullName, isActive: user.isActive },
newState: { deletedAt: new Date().toISOString() },
ipAddress: metadata?.ipAddress,
userAgent: metadata?.userAgent,
});
return result; return result;
} }
async restore(
id: string,
actorId: string,
metadata?: { ipAddress?: string; userAgent?: string }
) {
// Look for soft-deleted user
const user = await this.repository.findDeletedById(id);
if (!user) {
// Try active user (not deleted) - return conflict
const activeUser = await this.repository.findById(id);
if (activeUser) {
throw new AppError('Người dùng chưa bị xóa, không cần khôi phục', 400, ERROR_CODE.VALIDATION_ERROR);
}
throw new AppError('Người dùng không tồn tại', 404, ERROR_CODE.NOT_FOUND);
}
const restored = await this.repository.restore(id);
await rbacService.invalidateUserCache(id);
await this.rbacRepository.createAuditLog({
actorId,
action: 'USER_RESTORE',
targetType: 'USER',
targetId: id,
previousState: { deletedAt: user.deletedAt?.toISOString() },
newState: { isActive: true, deletedAt: null },
ipAddress: metadata?.ipAddress,
userAgent: metadata?.userAgent,
});
return restored;
}
async getAdminStats() {
return this.repository.getAdminStats();
}
} }
...@@ -21,9 +21,7 @@ export const createUserSchema = z.object({ ...@@ -21,9 +21,7 @@ export const createUserSchema = z.object({
.string() .string()
.min(1, 'Email is required') .min(1, 'Email is required')
.email('Invalid email format') .email('Invalid email format')
.refine((val) => val.endsWith('@gmail.com'), { .transform((val) => val.trim().toLowerCase()),
message: 'Only @gmail.com emails are allowed',
}),
password: z password: z
.string() .string()
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
......
import { WorkerEnv } from './types/worker-env'; import { WorkerEnv } from './types/worker-env';
import { getPrismaClient, setWorkerEnv } from './database/prisma.client'; import { getPrismaClient } from './database/prisma.client';
export default { export default {
async fetch(request: Request, env: WorkerEnv, ctx: any): Promise<Response> { async fetch(request: Request, env: WorkerEnv, _ctx: any): Promise<Response> {
setWorkerEnv(env);
const url = new URL(request.url); const url = new URL(request.url);
const pathname = url.pathname; const pathname = url.pathname;
...@@ -20,7 +18,7 @@ export default { ...@@ -20,7 +18,7 @@ export default {
if (pathname === '/' || pathname === '/health' || pathname === '/api/v1/health') { if (pathname === '/' || pathname === '/health' || pathname === '/api/v1/health') {
const startTime = Date.now(); const startTime = Date.now();
let dbStatus = 'disconnected'; let dbStatus: string;
let dbLatencyMs = 0; let dbLatencyMs = 0;
let userCount = 0; let userCount = 0;
let errorDetail: string | null = null; let errorDetail: string | null = null;
......
...@@ -40,8 +40,6 @@ describe('Auth Integration Tests', () => { ...@@ -40,8 +40,6 @@ describe('Auth Integration Tests', () => {
email: testUser.email, email: testUser.email,
}, },
}); });
await prisma.$disconnect();
}); });
let verificationToken = ''; let verificationToken = '';
......
...@@ -66,7 +66,6 @@ describe('Budget & Report Integration Tests', () => { ...@@ -66,7 +66,6 @@ describe('Budget & Report Integration Tests', () => {
await prisma.refreshToken.deleteMany({ where: { userId } }); await prisma.refreshToken.deleteMany({ where: { userId } });
await prisma.userDevice.deleteMany({ where: { userId } }); await prisma.userDevice.deleteMany({ where: { userId } });
await prisma.user.deleteMany({ where: { id: userId } }); await prisma.user.deleteMany({ where: { id: userId } });
await prisma.$disconnect();
}); });
it('should create budgets and query budget list with batch spending summaries without N+1 error', async () => { it('should create budgets and query budget list with batch spending summaries without N+1 error', async () => {
......
This diff is collapsed.
...@@ -6,6 +6,12 @@ process.env.JWT_REFRESH_SECRET = 'test_refresh_secret_key_123456789_xyz'; ...@@ -6,6 +6,12 @@ process.env.JWT_REFRESH_SECRET = 'test_refresh_secret_key_123456789_xyz';
process.env.REDIS_ENABLED = 'false'; process.env.REDIS_ENABLED = 'false';
process.env.NOTIFICATION_WORKER_ENABLED = 'false'; process.env.NOTIFICATION_WORKER_ENABLED = 'false';
// Limit Prisma connection pool in test runner to prevent Supabase session pool limit (15) exhaustion
if (process.env.DATABASE_URL && !process.env.DATABASE_URL.includes('connection_limit')) {
const separator = process.env.DATABASE_URL.includes('?') ? '&' : '?';
process.env.DATABASE_URL = `${process.env.DATABASE_URL}${separator}connection_limit=5&pool_timeout=30`;
}
// Mock MailService để tránh gửi mail thật và in log cảnh báo ra console // Mock MailService để tránh gửi mail thật và in log cảnh báo ra console
jest.mock('../src/common/services/mail.service', () => { jest.mock('../src/common/services/mail.service', () => {
return { return {
......
...@@ -44,7 +44,6 @@ describe('Wallet Integration Tests', () => { ...@@ -44,7 +44,6 @@ describe('Wallet Integration Tests', () => {
await prisma.refreshToken.deleteMany({ where: { userId } }); await prisma.refreshToken.deleteMany({ where: { userId } });
await prisma.userDevice.deleteMany({ where: { userId } }); await prisma.userDevice.deleteMany({ where: { userId } });
await prisma.user.deleteMany({ where: { id: userId } }); await prisma.user.deleteMany({ where: { id: userId } });
await prisma.$disconnect();
}); });
it('should create a new wallet with initial balance', async () => { it('should create a new wallet with initial balance', async () => {
......
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