Commit bd98f9fe authored by ThinhNC's avatar ThinhNC

Merge branch 'develop' into 'master'

Develop

See merge request !4
parents 9e8a7adc ab1b41a5
......@@ -14,6 +14,9 @@ importers:
bcryptjs:
specifier: ^2.4.3
version: 2.4.3
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
cors:
specifier: ^2.8.5
version: 2.8.6
......@@ -32,6 +35,9 @@ importers:
morgan:
specifier: ^1.10.0
version: 1.11.0
nodemailer:
specifier: ^9.0.3
version: 9.0.3
swagger-ui-express:
specifier: ^5.0.1
version: 5.0.1(express@4.22.2)
......@@ -42,6 +48,9 @@ importers:
'@types/bcryptjs':
specifier: ^2.4.6
version: 2.4.6
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@4.17.25)
'@types/cors':
specifier: ^2.8.17
version: 2.8.19
......@@ -57,6 +66,9 @@ importers:
'@types/node':
specifier: ^22.10.2
version: 22.20.0
'@types/nodemailer':
specifier: ^8.0.1
version: 8.0.1
'@types/swagger-ui-express':
specifier: ^4.1.8
version: 4.1.8
......@@ -360,6 +372,11 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cookie-parser@1.4.10':
resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==}
peerDependencies:
'@types/express': '*'
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
......@@ -393,6 +410,9 @@ packages:
'@types/node@22.20.0':
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
'@types/nodemailer@8.0.1':
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
......@@ -528,6 +548,13 @@ packages:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
cookie-parser@1.4.7:
resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
engines: {node: '>= 0.8.0'}
cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
cookie-signature@1.0.7:
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
......@@ -939,6 +966,10 @@ packages:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
nodemailer@9.0.3:
resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==}
engines: {node: '>=6.0.0'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
......@@ -1452,6 +1483,10 @@ snapshots:
dependencies:
'@types/node': 22.20.0
'@types/cookie-parser@1.4.10(@types/express@4.17.25)':
dependencies:
'@types/express': 4.17.25
'@types/cors@2.8.19':
dependencies:
'@types/node': 22.20.0
......@@ -1493,6 +1528,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/nodemailer@8.0.1':
dependencies:
'@types/node': 22.20.0
'@types/qs@6.15.1': {}
'@types/range-parser@1.2.7': {}
......@@ -1643,6 +1682,13 @@ snapshots:
content-type@1.0.5: {}
cookie-parser@1.4.7:
dependencies:
cookie: 0.7.2
cookie-signature: 1.0.6
cookie-signature@1.0.6: {}
cookie-signature@1.0.7: {}
cookie@0.7.2: {}
......@@ -2094,6 +2140,8 @@ snapshots:
negotiator@0.6.3: {}
nodemailer@9.0.3: {}
normalize-path@3.0.0: {}
object-assign@4.1.1: {}
......
......@@ -13,22 +13,29 @@ enum TransactionType {
}
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
password String
fullName String? @map("full_name")
isActive Boolean @default(true) @map("is_active")
roleId String @map("role_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default(uuid()) @db.Uuid
email String? @unique
password String?
fullName String? @map("full_name")
avatarUrl String? @map("avatar_url")
phoneNumber String? @unique @map("phone_number")
isActive Boolean @default(false) @map("is_active")
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by") @db.Uuid
roleId String @map("role_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
wallets Wallet[]
categories Category[]
transactions Transaction[]
budgets Budget[]
refreshTokens RefreshToken[]
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
wallets Wallet[]
categories Category[]
transactions Transaction[]
budgets Budget[]
refreshTokens RefreshToken[]
socialAccounts UserSocial[]
verificationTokens VerificationToken[]
passwordResetTokens PasswordResetToken[]
@@map("users")
}
......@@ -124,3 +131,41 @@ model RefreshToken {
@@index([userId])
@@map("refresh_tokens")
}
model UserSocial {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
provider String // "ZALO", "GOOGLE", etc.
providerUserId String @map("provider_user_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerUserId])
@@map("user_socials")
}
model VerificationToken {
id String @id @default(uuid()) @db.Uuid
token String @unique
userId String @map("user_id") @db.Uuid
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("verification_tokens")
}
model PasswordResetToken {
id String @id @default(uuid()) @db.Uuid
token String @unique
userId String @map("user_id") @db.Uuid
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("password_reset_tokens")
}
......@@ -4,7 +4,7 @@ import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
const roles = ['ADMIN', 'LEADER', 'INTERN'];
const roles = ['ADMIN', 'MANAGER', 'USER'];
const roleMap: Record<string, string> = {};
for (const roleName of roles) {
......@@ -20,11 +20,11 @@ async function main() {
const adminEmail = 'admin@finwise.local';
const adminPassword = await bcrypt.hash('Admin@123456', 10);
const leaderEmail = 'leader@finwise.local';
const leaderPassword = await bcrypt.hash('Leader@123456', 10);
const managerEmail = 'manager@finwise.local';
const managerPassword = await bcrypt.hash('Manager@123456', 10);
const internEmail = 'intern@finwise.local';
const internPassword = await bcrypt.hash('Intern@123456', 10);
const userEmail = 'user@finwise.local';
const userPassword = await bcrypt.hash('User@123456', 10);
await prisma.user.upsert({
where: { email: adminEmail },
......@@ -44,35 +44,35 @@ async function main() {
});
await prisma.user.upsert({
where: { email: leaderEmail },
where: { email: managerEmail },
update: {
password: leaderPassword,
fullName: 'Leader',
roleId: roleMap['LEADER'],
password: managerPassword,
fullName: 'Manager',
roleId: roleMap['MANAGER'],
isActive: true,
},
create: {
email: leaderEmail,
password: leaderPassword,
fullName: 'Leader',
roleId: roleMap['LEADER'],
email: managerEmail,
password: managerPassword,
fullName: 'Manager',
roleId: roleMap['MANAGER'],
isActive: true,
},
});
await prisma.user.upsert({
where: { email: internEmail },
where: { email: userEmail },
update: {
password: internPassword,
fullName: 'Intern',
roleId: roleMap['INTERN'],
password: userPassword,
fullName: 'User',
roleId: roleMap['USER'],
isActive: true,
},
create: {
email: internEmail,
password: internPassword,
fullName: 'Intern',
roleId: roleMap['INTERN'],
email: userEmail,
password: userPassword,
fullName: 'User',
roleId: roleMap['USER'],
isActive: true,
},
});
......
......@@ -3,6 +3,7 @@ import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import swaggerUi from 'swagger-ui-express';
import cookieParser from 'cookie-parser';
import { errorMiddleware, notFoundMiddleware } from './middlewares/error.middleware';
import routes from './routes';
import { swaggerSpec, swaggerOptions } from './config/swagger.config';
......@@ -17,6 +18,7 @@ app.use(cors());
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerOptions));
......
export const EXPORT_TYPE = {
JSON: 'JSON',
CSV: 'CSV',
XLSX: 'XLSX',
MARKDOWN: 'MARKDOWN',
MARKDOWN_ZIP: 'MARKDOWN_ZIP',
FULL_ZIP: 'FULL_ZIP',
} as const;
export type ExportType = keyof typeof EXPORT_TYPE;
export const JOB_STATUS = {
PENDING: 'PENDING',
RUNNING: 'RUNNING',
COMPLETED: 'COMPLETED',
PARTIAL_COMPLETED: 'PARTIAL_COMPLETED',
FAILED: 'FAILED',
CANCELED: 'CANCELED',
BLOCKED: 'BLOCKED',
} as const;
export type JobStatus = keyof typeof JOB_STATUS;
export const ROLES = {
ADMIN: 'ADMIN',
CRAWLER_USER: 'CRAWLER_USER',
VIEWER: 'VIEWER',
MANAGER: 'MANAGER',
USER: 'USER',
} as const;
export type Role = keyof typeof ROLES;
import nodemailer from 'nodemailer';
import { mailConfig } from '../../config/mail.config';
export class MailService {
private transporter: nodemailer.Transporter;
constructor() {
this.transporter = nodemailer.createTransport({
host: mailConfig.host,
port: mailConfig.port,
secure: mailConfig.secure,
auth: {
user: mailConfig.auth.user,
pass: mailConfig.auth.pass,
},
});
}
async sendVerificationEmail(email: string, token: string, fullName?: string) {
const verificationUrl = `${mailConfig.verificationUrl}?token=${token}`;
const mailOptions = {
from: mailConfig.from,
to: email,
subject: 'Xác thực tài khoản FinWise',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 5px;">
<h2 style="color: #4CAF50; text-align: center;">Chào mừng đến với FinWise!</h2>
<p>Chào ${fullName || 'bạn'},</p>
<p>Cảm ơn bạn đã đăng ký tài khoản tại FinWise - Sổ tay Chi tiêu & Báo cáo Tài chính.</p>
<p>Vui lòng xác nhận địa chỉ email của bạn để kích hoạt tài khoản bằng cách click vào nút bên dưới:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${verificationUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; display: inline-block;">Kích hoạt tài khoản</a>
</div>
<p>Hoặc bạn có thể sao chép liên kết dưới đây và dán vào trình duyệt của bạn:</p>
<p style="word-break: break-all; color: #888888;">${verificationUrl}</p>
<p>Liên kết này sẽ có hiệu lực trong vòng 24 giờ.</p>
<hr style="border: 0; border-top: 1px solid #e0e0e0; margin: 20px 0;" />
<p style="font-size: 12px; color: #888888; text-align: center;">Nếu bạn không thực hiện yêu cầu này, vui lòng bỏ qua email này.</p>
</div>
`,
};
if (!mailConfig.auth.user || !mailConfig.auth.pass) {
console.warn('-------- EMAIL VERIFICATION TOKEN (DEV MODE) --------');
console.warn(`To: ${email}`);
console.warn(`Link: ${verificationUrl}`);
console.warn('----------------------------------------------------');
return;
}
try {
await this.transporter.sendMail(mailOptions);
} catch (error) {
console.error('Failed to send verification email:', error);
throw error;
}
}
async sendPasswordResetEmail(email: string, token: string, fullName?: string) {
const resetUrl = `${mailConfig.verificationUrl.replace('verify-email', 'reset-password')}?token=${token}`;
const mailOptions = {
from: mailConfig.from,
to: email,
subject: 'Khôi phục mật khẩu tài khoản FinWise',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 5px;">
<h2 style="color: #f44336; text-align: center;">Khôi phục mật khẩu tài khoản</h2>
<p>Chào ${fullName || 'bạn'},</p>
<p>Chúng tôi nhận được yêu cầu khôi phục mật khẩu cho tài khoản FinWise liên kết với địa chỉ email này.</p>
<p>Vui lòng click vào nút bên dưới để tiến hành đặt lại mật khẩu mới:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${resetUrl}" style="background-color: #f44336; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; display: inline-block;">Đặt lại mật khẩu</a>
</div>
<p>Hoặc bạn có thể sao chép liên kết dưới đây và dán vào trình duyệt của bạn:</p>
<p style="word-break: break-all; color: #888888;">${resetUrl}</p>
<p>Liên kết này sẽ có hiệu lực trong vòng 1 giờ.</p>
<hr style="border: 0; border-top: 1px solid #e0e0e0; margin: 20px 0;" />
<p style="font-size: 12px; color: #888888; text-align: center;">Nếu bạn không thực hiện yêu cầu này, vui lòng bỏ qua email này. Mật khẩu của bạn sẽ không thay đổi.</p>
</div>
`,
};
if (!mailConfig.auth.user || !mailConfig.auth.pass) {
console.warn('-------- EMAIL PASSWORD RESET TOKEN (DEV MODE) --------');
console.warn(`To: ${email}`);
console.warn(`Link: ${resetUrl}`);
console.warn('-------------------------------------------------------');
return;
}
try {
await this.transporter.sendMail(mailOptions);
} catch (error) {
console.error('Failed to send password reset email:', error);
throw error;
}
}
}
export const mailConfig = {
host: process.env.MAIL_HOST || 'smtp.gmail.com',
port: parseInt(process.env.MAIL_PORT || '587', 10),
secure: process.env.MAIL_SECURE === 'true', // true for 465, false for 587
auth: {
user: process.env.MAIL_USER || '',
pass: process.env.MAIL_PASS || '',
},
from: process.env.MAIL_FROM || 'FinWise <noreply@gmail.com>',
verificationUrl: process.env.MAIL_VERIFICATION_URL || 'http://localhost:7777/api/v1/auth/verify-email',
};
......@@ -57,7 +57,7 @@ export const swaggerSpec = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', enum: ['ADMIN', 'LEADER', 'INTERN'] },
name: { type: 'string', enum: ['ADMIN', 'MANAGER', 'USER'] },
},
},
User: {
......@@ -100,8 +100,6 @@ export const swaggerSpec = {
LoginResponse: {
type: 'object',
properties: {
accessToken: { type: 'string' },
refreshToken: { type: 'string' },
user: { $ref: '#/components/schemas/User' },
},
},
......@@ -114,18 +112,63 @@ export const swaggerSpec = {
},
RefreshBody: {
type: 'object',
required: ['refreshToken'],
properties: {
refreshToken: { type: 'string' },
},
},
LogoutBody: {
type: 'object',
required: ['refreshToken'],
properties: {
refreshToken: { type: 'string' },
},
},
RegisterBody: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email', example: 'user@gmail.com' },
password: { type: 'string', minLength: 8, example: 'User@123456' },
fullName: { type: 'string', example: 'Nguyen Van A' },
},
},
UpdateProfileBody: {
type: 'object',
properties: {
fullName: { type: 'string', example: 'Nguyen Van B' },
avatarUrl: { type: 'string', format: 'uri', example: 'https://example.com/avatar.jpg' },
phoneNumber: { type: 'string', example: '0912345678' },
},
},
UpdatePasswordBody: {
type: 'object',
required: ['newPassword'],
properties: {
oldPassword: { type: 'string', example: 'OldPassword@123' },
newPassword: { type: 'string', minLength: 8, example: 'NewPassword@123' },
},
},
ForgotPasswordBody: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email', example: 'user@gmail.com' },
},
},
ResetPasswordBody: {
type: 'object',
required: ['token', 'newPassword'],
properties: {
token: { type: 'string', format: 'uuid', example: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' },
newPassword: { type: 'string', minLength: 8, example: 'NewPassword@123' },
},
},
ResendVerificationBody: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email', example: 'user@gmail.com' },
},
},
},
parameters: {
PageParam: { in: 'query', name: 'page', schema: { type: 'integer', default: 1 } },
......@@ -168,6 +211,53 @@ export const swaggerSpec = {
},
},
},
'/auth/register': {
post: {
tags: ['Auth'],
summary: 'Đăng ký tài khoản mới (chỉ cho phép gmail.com)',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/RegisterBody' } } } },
responses: {
201: {
description: 'Đăng ký thành công, vui lòng kiểm tra email để kích hoạt tài khoản',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { message: { type: 'string', example: 'Verification email sent' } } },
],
},
},
},
},
400: { description: 'Email đã tồn tại hoặc không phải gmail.com', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/auth/verify-email': {
get: {
tags: ['Auth'],
summary: 'Xác thực kích hoạt email',
parameters: [{ in: 'query', name: 'token', required: true, schema: { type: 'string', format: 'uuid' }, description: 'Mã xác thực gửi qua email' }],
responses: {
200: {
description: 'Kích hoạt tài khoản thành công',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { message: { type: 'string', example: 'Email verified successfully' } } },
],
},
},
},
},
400: { description: 'Token không hợp lệ hoặc đã hết hạn', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
},
},
},
'/auth/login': {
post: {
tags: ['Auth'],
......@@ -192,6 +282,129 @@ export const swaggerSpec = {
},
},
},
'/auth/resend-verification': {
post: {
tags: ['Auth'],
summary: 'Gửi lại email xác thực kích hoạt tài khoản',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/ResendVerificationBody' } } } },
responses: {
200: {
description: 'Gửi lại email xác thực thành công',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { message: { type: 'string', example: 'Verification email sent successfully' } } },
],
},
},
},
},
400: { description: 'Tài khoản đã được xác thực trước đó', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/auth/forgot-password': {
post: {
tags: ['Auth'],
summary: 'Yêu cầu đặt lại mật khẩu (quên mật khẩu)',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/ForgotPasswordBody' } } } },
responses: {
200: {
description: 'Đã gửi link khôi phục mật khẩu qua email',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { message: { type: 'string', example: 'Password reset link sent to your email' } } },
],
},
},
},
},
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/auth/reset-password': {
post: {
tags: ['Auth'],
summary: 'Đặt lại mật khẩu mới bằng token khôi phục',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/ResetPasswordBody' } } } },
responses: {
200: {
description: 'Đặt lại mật khẩu mới thành công',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { message: { type: 'string', example: 'Password has been reset successfully' } } },
],
},
},
},
},
400: { description: 'Token không hợp lệ hoặc đã hết hạn', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/auth/profile': {
put: {
tags: ['Auth'],
summary: 'Cập nhật thông tin cá nhân',
security: [{ BearerAuth: [] }],
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdateProfileBody' } } } },
responses: {
200: {
description: 'Cập nhật thông tin thành công',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { message: { type: 'string', example: 'Profile updated successfully' } } },
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/auth/password': {
put: {
tags: ['Auth'],
summary: 'Cập nhật mật khẩu',
security: [{ BearerAuth: [] }],
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdatePasswordBody' } } } },
responses: {
200: {
description: 'Cập nhật mật khẩu thành công',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { message: { type: 'string', example: 'Password updated successfully' } } },
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/auth/me': {
get: {
tags: ['Auth'],
......@@ -271,7 +484,7 @@ export const swaggerSpec = {
parameters: [
{ in: 'query', name: 'email', schema: { type: 'string' }, description: 'Tìm theo email' },
{ in: 'query', name: 'fullName', schema: { type: 'string' }, description: 'Tìm theo tên' },
{ in: 'query', name: 'roleName', schema: { type: 'string', enum: ['ADMIN', 'LEADER', 'INTERN'] }, description: 'Lọc theo role' },
{ in: 'query', name: 'roleName', schema: { type: 'string', enum: ['ADMIN', 'MANAGER', 'USER'] }, description: 'Lọc theo role' },
{ in: 'query', name: 'isActive', schema: { type: 'string', enum: ['true', 'false'] }, description: 'Lọc theo trạng thái' },
{ in: 'query', name: 'sortBy', schema: { type: 'string', enum: ['createdAt', 'email', 'fullName'], default: 'createdAt' } },
{ $ref: '#/components/parameters/OrderParam' },
......@@ -379,6 +592,30 @@ export const swaggerSpec = {
422: { $ref: '#/components/responses/Validation' },
},
},
delete: {
tags: ['Users'],
summary: 'Xóa mềm tài khoản',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: {
200: {
description: 'Xóa mềm tài khoản thành công',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { message: { type: 'string', example: 'User soft deleted successfully' } } },
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
},
},
},
},
};
\ No newline at end of file
......@@ -5,15 +5,20 @@ import { AppError } from '../common/errors/app-error';
import { ERROR_CODE } from '../common/errors/error-code';
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
let token = req.cookies?.accessToken;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
if (!token) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.split(' ')[1];
}
}
if (!token) {
next(new AppError('Unauthorized', 401, ERROR_CODE.UNAUTHORIZED));
return;
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, jwtConfig.accessSecret) as {
id: string;
......
import { Request, Response, NextFunction } from 'express';
import { AuthService } from './auth.service';
import { LoginDto } from './auth.dto';
import { LoginDto, RegisterDto, UpdateProfileDto, UpdatePasswordDto, ForgotPasswordDto, ResetPasswordDto, ResendVerificationDto } from './auth.dto';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
export class AuthController {
private readonly service = new AuthService();
......@@ -12,9 +14,25 @@ export class AuthController {
const ipAddress = req.ip;
const result = await this.service.login(body, { userAgent, ipAddress });
res.cookie('accessToken', result.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000, // 24 hours
});
res.cookie('refreshToken', result.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
res.json({
success: true,
data: result,
data: {
user: result.user,
},
});
} catch (error) {
next(error);
......@@ -36,14 +54,31 @@ export class AuthController {
refresh = async (req: Request, res: Response, next: NextFunction) => {
try {
const { refreshToken } = req.body;
const refreshToken = req.cookies?.refreshToken || req.body.refreshToken;
if (!refreshToken) {
throw new AppError('Refresh token is required', 400, ERROR_CODE.TOKEN_INVALID);
}
const userAgent = req.headers['user-agent'];
const ipAddress = req.ip;
const result = await this.service.refresh(refreshToken, { userAgent, ipAddress });
res.cookie('accessToken', result.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000,
});
res.cookie('refreshToken', result.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
......@@ -52,8 +87,13 @@ export class AuthController {
logout = async (req: Request, res: Response, next: NextFunction) => {
try {
const { refreshToken } = req.body;
await this.service.logout(refreshToken);
const refreshToken = req.cookies?.refreshToken || req.body.refreshToken;
if (refreshToken) {
await this.service.logout(refreshToken);
}
res.clearCookie('accessToken');
res.clearCookie('refreshToken');
res.json({
success: true,
......@@ -63,4 +103,102 @@ export class AuthController {
next(error);
}
};
register = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as RegisterDto;
await this.service.register(body);
res.status(201).json({
success: true,
message: 'Registration successful. Please check your email to verify your account.',
});
} catch (error) {
next(error);
}
};
verifyEmail = async (req: Request, res: Response, next: NextFunction) => {
try {
const { token } = req.query;
await this.service.verifyEmail(token as string);
res.json({
success: true,
message: 'Email verified successfully. You can now log in.',
});
} catch (error) {
next(error);
}
};
updateProfile = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as UpdateProfileDto;
await this.service.updateProfile(req.user.id, body);
res.json({
success: true,
message: 'Profile updated successfully',
});
} catch (error) {
next(error);
}
};
updatePassword = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as UpdatePasswordDto;
await this.service.updatePassword(req.user.id, body);
res.json({
success: true,
message: 'Password updated successfully',
});
} catch (error) {
next(error);
}
};
forgotPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as ForgotPasswordDto;
await this.service.forgotPassword(body);
res.json({
success: true,
message: 'Password reset link sent to your email',
});
} catch (error) {
next(error);
}
};
resetPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as ResetPasswordDto;
await this.service.resetPassword(body);
res.json({
success: true,
message: 'Password has been reset successfully',
});
} catch (error) {
next(error);
}
};
resendVerification = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as ResendVerificationDto;
await this.service.resendVerification(body);
res.json({
success: true,
message: 'Verification email sent successfully',
});
} catch (error) {
next(error);
}
};
}
......@@ -3,6 +3,12 @@ export interface LoginDto {
password: string;
}
export interface RegisterDto {
email: string;
password: string;
fullName?: string;
}
export interface AuthTokensDto {
accessToken: string;
refreshToken: string;
......@@ -10,7 +16,7 @@ export interface AuthTokensDto {
export interface MeDto {
id: string;
email: string;
email: string | null;
fullName: string | null;
role: string;
isActive: boolean;
......@@ -22,8 +28,32 @@ export interface LoginResponseDto {
refreshToken: string;
user: {
id: string;
email: string;
email: string | null;
fullName: string | null;
role: string;
};
}
export interface UpdateProfileDto {
fullName?: string;
avatarUrl?: string;
phoneNumber?: string;
}
export interface UpdatePasswordDto {
oldPassword?: string;
newPassword: string;
}
export interface ForgotPasswordDto {
email: string;
}
export interface ResetPasswordDto {
token: string;
newPassword: string;
}
export interface ResendVerificationDto {
email: string;
}
......@@ -2,15 +2,15 @@ import { prisma } from '../../database/prisma.client';
export class AuthRepository {
findByEmail(email: string) {
return prisma.user.findUnique({
where: { email },
return prisma.user.findFirst({
where: { email, deletedAt: null },
include: { role: true },
});
}
findById(id: string) {
return prisma.user.findUnique({
where: { id },
return prisma.user.findFirst({
where: { id, deletedAt: null },
include: { role: true },
});
}
......@@ -38,4 +38,158 @@ export class AuthRepository {
where: { token },
});
}
async findBySocial(provider: string, providerUserId: string) {
const socialAccount = await prisma.userSocial.findUnique({
where: {
provider_providerUserId: {
provider,
providerUserId,
},
},
include: {
user: {
include: { role: true },
},
},
});
return socialAccount?.user || null;
}
async createSocialUser(data: {
fullName?: string;
avatarUrl?: string;
roleId: string;
provider: string;
providerUserId: string;
}) {
return prisma.user.create({
data: {
fullName: data.fullName,
avatarUrl: data.avatarUrl,
roleId: data.roleId,
isActive: true, // Social users are active immediately
socialAccounts: {
create: {
provider: data.provider,
providerUserId: data.providerUserId,
},
},
},
include: { role: true },
});
}
async createVerificationToken(userId: string, token: string, expiresAt: Date) {
return prisma.verificationToken.create({
data: {
userId,
token,
expiresAt,
},
});
}
async findVerificationToken(token: string) {
return prisma.verificationToken.findUnique({
where: { token },
include: { user: true },
});
}
async activateUser(userId: string) {
return prisma.user.update({
where: { id: userId },
data: { isActive: true },
});
}
async deleteVerificationToken(tokenId: string) {
return prisma.verificationToken.delete({
where: { id: tokenId },
});
}
async findRoleByName(name: string) {
return prisma.role.findUnique({
where: { name },
});
}
async createUser(data: {
email: string;
passwordHash: string;
fullName?: string;
roleId: string;
isActive: boolean;
}) {
return prisma.user.create({
data: {
email: data.email,
password: data.passwordHash,
fullName: data.fullName,
roleId: data.roleId,
isActive: data.isActive,
},
});
}
async findByPhone(phoneNumber: string) {
return prisma.user.findFirst({
where: { phoneNumber, deletedAt: null },
});
}
async updateProfile(userId: string, data: { fullName?: string; avatarUrl?: string; phoneNumber?: string }) {
return prisma.user.update({
where: { id: userId },
data,
});
}
async updatePassword(userId: string, passwordHash: string) {
return prisma.user.update({
where: { id: userId },
data: { password: passwordHash },
});
}
async softDelete(userId: string, adminId: string) {
return prisma.$transaction([
prisma.user.update({
where: { id: userId },
data: {
deletedAt: new Date(),
deletedBy: adminId,
isActive: false,
},
}),
prisma.refreshToken.deleteMany({
where: { userId },
}),
]);
}
async createPasswordResetToken(userId: string, token: string, expiresAt: Date) {
return prisma.passwordResetToken.create({
data: {
userId,
token,
expiresAt,
},
});
}
async findPasswordResetToken(token: string) {
return prisma.passwordResetToken.findUnique({
where: { token },
include: { user: true },
});
}
async deletePasswordResetToken(tokenId: string) {
return prisma.passwordResetToken.delete({
where: { id: tokenId },
});
}
}
......@@ -2,14 +2,21 @@ import { Router } from 'express';
import { AuthController } from './auth.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { loginSchema, refreshSchema, logoutSchema } from './auth.validation';
import { loginSchema, refreshSchema, logoutSchema, registerSchema, verifyEmailSchema, updateProfileSchema, updatePasswordSchema, forgotPasswordSchema, resetPasswordSchema, resendVerificationSchema } from './auth.validation';
const router = Router();
const controller = new AuthController();
router.post('/register', validate(registerSchema), controller.register);
router.get('/verify-email', validate(verifyEmailSchema, 'query'), controller.verifyEmail);
router.post('/login', validate(loginSchema), controller.login);
router.get('/me', authMiddleware, controller.me);
router.post('/refresh', validate(refreshSchema), controller.refresh);
router.post('/logout', validate(logoutSchema), controller.logout);
router.put('/profile', authMiddleware, validate(updateProfileSchema), controller.updateProfile);
router.put('/password', authMiddleware, validate(updatePasswordSchema), controller.updatePassword);
router.post('/forgot-password', validate(forgotPasswordSchema), controller.forgotPassword);
router.post('/reset-password', validate(resetPasswordSchema), controller.resetPassword);
router.post('/resend-verification', validate(resendVerificationSchema), controller.resendVerification);
export default router;
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { AuthRepository } from './auth.repository';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { jwtConfig } from '../../config/jwt.config';
import { LoginDto, LoginResponseDto, AuthTokensDto, MeDto } from './auth.dto';
import { LoginDto, LoginResponseDto, AuthTokensDto, MeDto, RegisterDto, UpdateProfileDto, UpdatePasswordDto, ForgotPasswordDto, ResetPasswordDto, ResendVerificationDto } from './auth.dto';
import { MailService } from '../../common/services/mail.service';
import { ROLES } from '../../common/constants/role.constant';
export class AuthService {
private readonly repository = new AuthRepository();
private readonly mailService = new MailService();
async login(data: LoginDto, metadata?: { userAgent?: string; ipAddress?: string }): Promise<LoginResponseDto> {
const { email, password } = data;
......@@ -21,6 +25,10 @@ export class AuthService {
throw new AppError('Account is inactive', 403, ERROR_CODE.USER_INACTIVE);
}
if (!user.password) {
throw new AppError('Invalid credentials', 401, ERROR_CODE.INVALID_CREDENTIALS);
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
......@@ -122,4 +130,147 @@ export class AuthService {
createdAt: user.createdAt,
};
}
async register(data: RegisterDto): Promise<void> {
const { email, password, fullName } = data;
const existing = await this.repository.findByEmail(email);
if (existing) {
throw new AppError('Email already exists', 400, ERROR_CODE.DUPLICATE_ENTRY);
}
const passwordHash = await bcrypt.hash(password, 10);
const defaultRole = await this.repository.findRoleByName(ROLES.USER);
if (!defaultRole) {
throw new AppError('Default role not found', 500, ERROR_CODE.NOT_FOUND);
}
const user = await this.repository.createUser({
email,
passwordHash,
fullName,
roleId: defaultRole.id,
isActive: false,
});
const token = crypto.randomUUID();
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
await this.repository.createVerificationToken(user.id, token, expiresAt);
await this.mailService.sendVerificationEmail(email, token, fullName || undefined);
}
async verifyEmail(token: string): Promise<void> {
const verificationToken = await this.repository.findVerificationToken(token);
if (!verificationToken) {
throw new AppError('Invalid verification token', 400, ERROR_CODE.TOKEN_INVALID);
}
if (verificationToken.expiresAt < new Date()) {
await this.repository.deleteVerificationToken(verificationToken.id);
throw new AppError('Verification token has expired', 400, ERROR_CODE.TOKEN_EXPIRED);
}
await this.repository.activateUser(verificationToken.userId);
await this.repository.deleteVerificationToken(verificationToken.id);
}
async updateProfile(userId: string, data: UpdateProfileDto): Promise<void> {
const user = await this.repository.findById(userId);
if (!user) {
throw new AppError('User not found', 404, ERROR_CODE.NOT_FOUND);
}
if (data.phoneNumber) {
const existingPhone = await this.repository.findByPhone(data.phoneNumber);
if (existingPhone && existingPhone.id !== userId) {
throw new AppError('Phone number already exists', 400, ERROR_CODE.DUPLICATE_ENTRY);
}
}
await this.repository.updateProfile(userId, data);
}
async updatePassword(userId: string, data: UpdatePasswordDto): Promise<void> {
const user = await this.repository.findById(userId);
if (!user) {
throw new AppError('User not found', 404, ERROR_CODE.NOT_FOUND);
}
if (user.password) {
if (!data.oldPassword) {
throw new AppError('Old password is required', 400, ERROR_CODE.INVALID_CREDENTIALS);
}
const isPasswordValid = await bcrypt.compare(data.oldPassword, user.password);
if (!isPasswordValid) {
throw new AppError('Invalid old password', 401, ERROR_CODE.INVALID_CREDENTIALS);
}
}
const passwordHash = await bcrypt.hash(data.newPassword, 10);
await this.repository.updatePassword(userId, passwordHash);
}
async softDeleteUser(userId: string, adminId: string): Promise<void> {
const user = await this.repository.findById(userId);
if (!user) {
throw new AppError('User not found', 404, ERROR_CODE.NOT_FOUND);
}
await this.repository.softDelete(userId, adminId);
}
async forgotPassword(data: ForgotPasswordDto): Promise<void> {
const user = await this.repository.findByEmail(data.email);
if (!user) {
throw new AppError('User not found', 404, ERROR_CODE.NOT_FOUND);
}
const token = crypto.randomUUID();
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1);
await this.repository.createPasswordResetToken(user.id, token, expiresAt);
await this.mailService.sendPasswordResetEmail(user.email!, token, user.fullName || undefined);
}
async resetPassword(data: ResetPasswordDto): Promise<void> {
const resetToken = await this.repository.findPasswordResetToken(data.token);
if (!resetToken) {
throw new AppError('Invalid or expired reset token', 400, ERROR_CODE.TOKEN_INVALID);
}
if (resetToken.expiresAt < new Date()) {
await this.repository.deletePasswordResetToken(resetToken.id);
throw new AppError('Reset token has expired', 400, ERROR_CODE.TOKEN_EXPIRED);
}
const passwordHash = await bcrypt.hash(data.newPassword, 10);
await this.repository.updatePassword(resetToken.userId, passwordHash);
await this.repository.deletePasswordResetToken(resetToken.id);
}
async resendVerification(data: ResendVerificationDto): Promise<void> {
const user = await this.repository.findByEmail(data.email);
if (!user) {
throw new AppError('User not found', 404, ERROR_CODE.NOT_FOUND);
}
if (user.isActive) {
throw new AppError('User is already verified', 400, ERROR_CODE.VALIDATION_ERROR);
}
const token = crypto.randomUUID();
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
await this.repository.createVerificationToken(user.id, token, expiresAt);
await this.mailService.sendVerificationEmail(user.email!, token, user.fullName || undefined);
}
}
......@@ -6,9 +6,67 @@ export const loginSchema = z.object({
});
export const refreshSchema = z.object({
refreshToken: z.string().min(1, 'Refresh token is required'),
refreshToken: z.string().optional(),
});
export const logoutSchema = z.object({
refreshToken: z.string().min(1, 'Refresh token is required'),
refreshToken: z.string().optional(),
});
export const registerSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Invalid email format')
.refine((val) => val.endsWith('@gmail.com'), {
message: 'Only @gmail.com emails are allowed',
}),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character'),
fullName: z.string().optional(),
});
export const verifyEmailSchema = z.object({
token: z.string().min(1, 'Verification token is required'),
});
export const updateProfileSchema = z.object({
fullName: z.string().min(1, 'Full name cannot be empty').optional(),
avatarUrl: z.string().url('Invalid avatar URL format').optional().or(z.literal('')),
phoneNumber: z.string().regex(/^[0-9]{10,11}$/, 'Invalid phone number format (must be 10-11 digits)').optional(),
});
export const updatePasswordSchema = z.object({
oldPassword: z.string().optional(),
newPassword: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character'),
});
export const forgotPasswordSchema = z.object({
email: z.string().min(1, 'Email is required').email('Invalid email format'),
});
export const resetPasswordSchema = z.object({
token: z.string().min(1, 'Reset token is required'),
newPassword: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character'),
});
export const resendVerificationSchema = z.object({
email: z.string().min(1, 'Email is required').email('Invalid email format'),
});
......@@ -56,4 +56,19 @@ export class UserController {
next(error);
}
};
softDelete = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const adminId = req.user.id;
await this.service.softDelete(id, adminId);
res.json({
success: true,
message: 'User soft-deleted successfully',
});
} catch (error) {
next(error);
}
};
}
......@@ -16,6 +16,7 @@ export class UserRepository {
} = query;
const where: Prisma.UserWhereInput = {
deletedAt: null,
...(email ? { email: { contains: email, mode: 'insensitive' } } : {}),
...(fullName ? { fullName: { contains: fullName, mode: 'insensitive' } } : {}),
...(isActive !== undefined ? { isActive } : {}),
......@@ -42,29 +43,37 @@ export class UserRepository {
}
findById(id: string) {
return prisma.user.findUnique({
where: { id },
return prisma.user.findFirst({
where: { id, deletedAt: null },
include: { role: true },
});
}
findByEmail(email: string) {
return prisma.user.findUnique({
where: { email },
return prisma.user.findFirst({
where: { email, deletedAt: null },
include: { role: true },
});
}
create(data: {
email: string;
passwordHash: string;
email?: string;
passwordHash?: string;
fullName?: string;
avatarUrl?: string;
phoneNumber?: string;
roleId: string;
isActive?: boolean;
}) {
return prisma.user.create({
data: {
email: data.email,
password: data.passwordHash,
fullName: data.fullName,
avatarUrl: data.avatarUrl,
phoneNumber: data.phoneNumber,
roleId: data.roleId,
isActive: data.isActive,
},
include: { role: true },
});
......@@ -77,4 +86,20 @@ export class UserRepository {
include: { role: true },
});
}
async softDelete(id: string, adminId: string) {
return prisma.$transaction([
prisma.user.update({
where: { id },
data: {
deletedAt: new Date(),
deletedBy: adminId,
isActive: false,
},
}),
prisma.refreshToken.deleteMany({
where: { userId: id },
}),
]);
}
}
......@@ -4,14 +4,16 @@ import { authMiddleware } from '../../middlewares/auth.middleware';
import { requireRole } from '../../middlewares/role.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { createUserSchema, findAllUserSchema, updateUserSchema } from './user.validation';
import { ROLES } from '../../common/constants/role.constant';
const router = Router();
const controller = new UserController();
// GET /users?email=...&fullName=...&roleName=...&isActive=...&sortBy=...&order=...&page=...&limit=...
router.get('/', authMiddleware, requireRole('ADMIN'), validate(findAllUserSchema, 'query'), controller.findAll);
router.get('/:id', authMiddleware, requireRole('ADMIN'), controller.findById);
router.post('/', authMiddleware, requireRole('ADMIN'), validate(createUserSchema), controller.create);
router.put('/:id', authMiddleware, requireRole('ADMIN'), validate(updateUserSchema), controller.update);
router.get('/', authMiddleware, requireRole(ROLES.ADMIN), validate(findAllUserSchema, 'query'), controller.findAll);
router.get('/:id', authMiddleware, requireRole(ROLES.ADMIN), controller.findById);
router.post('/', authMiddleware, requireRole(ROLES.ADMIN), validate(createUserSchema), controller.create);
router.put('/:id', authMiddleware, requireRole(ROLES.ADMIN), validate(updateUserSchema), controller.update);
router.delete('/:id', authMiddleware, requireRole(ROLES.ADMIN), controller.softDelete);
export default router;
......@@ -34,6 +34,7 @@ export class UserService {
email: data.email,
passwordHash,
roleId: data.roleId,
isActive: true, // Admin-created users are active by default
});
}
......@@ -45,4 +46,9 @@ export class UserService {
roleId: data.roleId,
});
}
async softDelete(id: string, adminId: string) {
await this.findById(id);
return this.repository.softDelete(id, adminId);
}
}
import { z } from 'zod';
import { ROLES } from '../../common/constants/role.constant';
export const findAllUserSchema = z.object({
email: z.string().optional(),
fullName: z.string().optional(),
roleName: z.enum(['ADMIN', 'LEADER', 'INTERN']).optional(),
roleName: z.enum([ROLES.ADMIN, ROLES.MANAGER, ROLES.USER]).optional(),
isActive: z
.enum(['true', 'false'])
.transform((v) => v === 'true')
......@@ -17,7 +18,13 @@ export const findAllUserSchema = z.object({
});
export const createUserSchema = z.object({
email: z.string().min(1, 'Email is required').email('Invalid email format'),
email: z
.string()
.min(1, 'Email is required')
.email('Invalid email format')
.refine((val) => val.endsWith('@gmail.com'), {
message: 'Only @gmail.com emails are allowed',
}),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
......
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