Merge branch 'master' into feature/cases
This commit is contained in:
20
backend/src/common/utils/mobile.util.ts
Normal file
20
backend/src/common/utils/mobile.util.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
const IRAN_MOBILE_PATTERN = /^9\d{9}$/;
|
||||
|
||||
/** Normalize Iranian mobile to sms.ir format (e.g. 9123456789). */
|
||||
export function normalizeIranMobile(input: string): string {
|
||||
let digits = input.replace(/\D/g, '');
|
||||
|
||||
if (digits.startsWith('98') && digits.length === 12) {
|
||||
digits = digits.slice(2);
|
||||
}
|
||||
|
||||
if (digits.startsWith('0') && digits.length === 11) {
|
||||
digits = digits.slice(1);
|
||||
}
|
||||
|
||||
return digits;
|
||||
}
|
||||
|
||||
export function isValidIranMobile(input: string): boolean {
|
||||
return IRAN_MOBILE_PATTERN.test(normalizeIranMobile(input));
|
||||
}
|
||||
@@ -46,6 +46,10 @@ export interface Config {
|
||||
ttl: number;
|
||||
limit: number;
|
||||
};
|
||||
sms: {
|
||||
apiKey: string | null;
|
||||
templateId: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default (): Config => {
|
||||
@@ -100,5 +104,9 @@ export default (): Config => {
|
||||
ttl: getEnvVarAsNumber('THROTTLE_TTL', 60),
|
||||
limit: getEnvVarAsNumber('THROTTLE_LIMIT', 100),
|
||||
},
|
||||
sms: {
|
||||
apiKey: process.env.SMS_IR_API_KEY?.trim() || null,
|
||||
templateId: getEnvVarAsNumber('SMS_IR_TEMPLATE_ID', 123456),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -31,10 +31,18 @@ import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { LocalAuthGuard } from './guards/local-auth.guard';
|
||||
import { UpdateLanguageDto } from './dto/update-language.dto';
|
||||
import {
|
||||
ForgotPasswordSendCodeDto,
|
||||
ForgotPasswordVerifyDto,
|
||||
} from './dto/forgot-password.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
private static readonly REMEMBER_ME_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
private static readonly PASSWORD_RESET_MAX_AGE_MS = 15 * 60 * 1000;
|
||||
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
// =========================
|
||||
@@ -56,9 +64,14 @@ export class AuthController {
|
||||
console.log('Login endpoint hit');
|
||||
|
||||
const result = await this.authService.login(loginDto, req.user);
|
||||
const rememberMe = Boolean(loginDto.rememberMe);
|
||||
|
||||
// ✅ SET COOKIES HERE
|
||||
this.setAuthCookies(res, result.data.accessToken, result.data.refreshToken);
|
||||
this.setAuthCookies(
|
||||
res,
|
||||
result.data.accessToken,
|
||||
result.data.refreshToken,
|
||||
rememberMe,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -113,8 +126,11 @@ export class AuthController {
|
||||
organizationId
|
||||
);
|
||||
|
||||
// 🔥 Replace access token with org-scoped token
|
||||
this.setAccessToken(res, result.data.accessToken);
|
||||
this.setAccessToken(
|
||||
res,
|
||||
result.data.accessToken,
|
||||
this.isPersistentSession(req),
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -160,6 +176,67 @@ export class AuthController {
|
||||
return this.authService.updateLanguage(req.user.id, dto);
|
||||
}
|
||||
|
||||
@Patch('profile/password')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Change account password' })
|
||||
async changePassword(
|
||||
@Req() req,
|
||||
@Body() dto: ChangePasswordDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const skipCurrentPassword = req?.cookies?.passwordResetVerified === '1';
|
||||
|
||||
const result = await this.authService.changePassword(
|
||||
req.user.id,
|
||||
dto.currentPassword,
|
||||
dto.newPassword,
|
||||
skipCurrentPassword,
|
||||
);
|
||||
|
||||
this.clearAuthCookies(res);
|
||||
res.clearCookie('passwordResetVerified', this.baseCookieOptions());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('forgot-password/send-code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Send forgot-password SMS verification code' })
|
||||
async sendForgotPasswordCode(@Body() dto: ForgotPasswordSendCodeDto) {
|
||||
return this.authService.sendForgotPasswordCode(dto);
|
||||
}
|
||||
|
||||
@Post('forgot-password/verify')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Verify SMS code and sign in for password reset' })
|
||||
async verifyForgotPasswordCode(
|
||||
@Body() dto: ForgotPasswordVerifyDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const result = await this.authService.verifyForgotPasswordCode(dto);
|
||||
|
||||
this.setAuthCookies(
|
||||
res,
|
||||
result.data.accessToken,
|
||||
result.data.refreshToken,
|
||||
);
|
||||
|
||||
res.cookie('passwordResetVerified', '1', {
|
||||
...this.baseCookieOptions(),
|
||||
maxAge: AuthController.PASSWORD_RESET_MAX_AGE_MS,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
user: result.data.user,
|
||||
organizations: result.data.organizations,
|
||||
redirectTo: '/settings/account',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('subscription-alert')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@@ -191,7 +268,11 @@ export class AuthController {
|
||||
|
||||
const result = await this.authService.refreshToken(refreshToken);
|
||||
|
||||
this.setAccessToken(res, result.data.accessToken);
|
||||
this.setAccessToken(
|
||||
res,
|
||||
result.data.accessToken,
|
||||
this.isPersistentSession(req),
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -235,45 +316,65 @@ export class AuthController {
|
||||
// =========================
|
||||
// 🔥 COOKIE HELPERS
|
||||
// =========================
|
||||
private isPersistentSession(req: { cookies?: Record<string, string> }): boolean {
|
||||
return req?.cookies?.authRemember === '1';
|
||||
}
|
||||
|
||||
private baseCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: false, // ⚠️ true in production (HTTPS)
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
};
|
||||
}
|
||||
|
||||
private setAuthCookies(
|
||||
res: Response,
|
||||
accessToken: string,
|
||||
refreshToken: string
|
||||
refreshToken: string,
|
||||
rememberMe = false,
|
||||
) {
|
||||
this.setAccessToken(res, accessToken);
|
||||
this.setRefreshToken(res, refreshToken);
|
||||
this.setAccessToken(res, accessToken, rememberMe);
|
||||
this.setRefreshToken(res, refreshToken, rememberMe);
|
||||
this.setRememberMeFlag(res, rememberMe);
|
||||
}
|
||||
|
||||
private setAccessToken(res: Response, token: string) {
|
||||
private setAccessToken(res: Response, token: string, rememberMe = false) {
|
||||
res.cookie('accessToken', token, {
|
||||
httpOnly: true,
|
||||
secure: false, // ⚠️ true in production (HTTPS)
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
...this.baseCookieOptions(),
|
||||
...(rememberMe
|
||||
? { maxAge: AuthController.REMEMBER_ME_MAX_AGE_MS }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
private setRefreshToken(res: Response, token: string) {
|
||||
private setRefreshToken(res: Response, token: string, rememberMe = false) {
|
||||
res.cookie('refreshToken', token, {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
...this.baseCookieOptions(),
|
||||
...(rememberMe
|
||||
? { maxAge: AuthController.REMEMBER_ME_MAX_AGE_MS }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
private setRememberMeFlag(res: Response, rememberMe: boolean) {
|
||||
if (rememberMe) {
|
||||
res.cookie('authRemember', '1', {
|
||||
...this.baseCookieOptions(),
|
||||
maxAge: AuthController.REMEMBER_ME_MAX_AGE_MS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.clearCookie('authRemember', this.baseCookieOptions());
|
||||
}
|
||||
|
||||
private clearAuthCookies(res: Response) {
|
||||
res.clearCookie('accessToken', {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
res.clearCookie('refreshToken', {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
const options = this.baseCookieOptions();
|
||||
res.clearCookie('accessToken', options);
|
||||
res.clearCookie('refreshToken', options);
|
||||
res.clearCookie('authRemember', options);
|
||||
res.clearCookie('passwordResetVerified', options);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import { AuthController } from './auth.controller';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { SmsModule } from '../sms/sms.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
SmsModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
|
||||
@@ -21,6 +21,18 @@ import {
|
||||
} from './dto/update-language.dto';
|
||||
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||
import { ownerPermissionsForOrgType, type OrganizationTypeName } from '../../common/organization-type';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import {
|
||||
ForgotPasswordSendCodeDto,
|
||||
ForgotPasswordVerifyDto,
|
||||
} from './dto/forgot-password.dto';
|
||||
import { normalizeIranMobile } from '../../common/utils/mobile.util';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const FORGOT_PASSWORD_PURPOSE = 'forgot_password';
|
||||
const VERIFICATION_CODE_TTL_MS = 10 * 60 * 1000;
|
||||
const SEND_CODE_COOLDOWN_MS = 60 * 1000;
|
||||
const PASSWORD_RESET_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
const ALL_PERMISSIONS = [
|
||||
'TAB_TODAY_READ',
|
||||
@@ -62,6 +74,7 @@ export class AuthService {
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
private smsService: SmsService,
|
||||
) { }
|
||||
|
||||
private accessJwtSignOptions(): JwtSignOptions {
|
||||
@@ -208,13 +221,24 @@ export class AuthService {
|
||||
const { password, name, organizationName, organizationEmail, organizationType } = registerDto;
|
||||
const email = registerDto.email.trim().toLowerCase();
|
||||
|
||||
if (!RegisterDto.isValidMobile(registerDto.mobile)) {
|
||||
throw new BadRequestException('Please enter a valid mobile number');
|
||||
}
|
||||
|
||||
const mobile = normalizeIranMobile(registerDto.mobile);
|
||||
|
||||
// 1. Check existing user
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
const existingUser = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email }, { mobile }],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictException('User already exists. Please login and create a new organization from your account.');
|
||||
if (existingUser.email === email) {
|
||||
throw new ConflictException('User already exists. Please login and create a new organization from your account.');
|
||||
}
|
||||
throw new ConflictException('This mobile number is already registered.');
|
||||
}
|
||||
|
||||
// 2. Hash password
|
||||
@@ -226,6 +250,7 @@ export class AuthService {
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
mobile,
|
||||
passwordHash: hashedPassword,
|
||||
name,
|
||||
trialUsedAt: new Date(),
|
||||
@@ -531,11 +556,17 @@ export class AuthService {
|
||||
/**
|
||||
* Change user password
|
||||
* @param userId - User ID
|
||||
* @param oldPassword - Current password
|
||||
* @param oldPassword - Current password (optional when reset verified via SMS)
|
||||
* @param newPassword - New password
|
||||
* @param skipCurrentPassword - True when user verified mobile via forgot-password flow
|
||||
* @returns Success message
|
||||
*/
|
||||
async changePassword(userId: string, oldPassword: string, newPassword: string) {
|
||||
async changePassword(
|
||||
userId: string,
|
||||
oldPassword: string | undefined,
|
||||
newPassword: string,
|
||||
skipCurrentPassword = false,
|
||||
) {
|
||||
try {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -545,22 +576,29 @@ export class AuthService {
|
||||
throw new BadRequestException('User not found or invalid password method');
|
||||
}
|
||||
|
||||
// Verify old password
|
||||
const isPasswordValid = await bcrypt.compare(oldPassword, user.passwordHash);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Current password is incorrect');
|
||||
if (skipCurrentPassword) {
|
||||
const hasRecentReset = await this.hasRecentPasswordResetVerification(userId);
|
||||
if (!hasRecentReset) {
|
||||
throw new UnauthorizedException('Password reset verification expired. Please verify your mobile again.');
|
||||
}
|
||||
} else {
|
||||
if (!oldPassword) {
|
||||
throw new BadRequestException('Current password is required');
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(oldPassword, user.passwordHash);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Current password is incorrect');
|
||||
}
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Update password
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash: hashedPassword },
|
||||
});
|
||||
|
||||
// Invalidate all sessions for this user (force re-login)
|
||||
await this.prisma.session.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
@@ -577,6 +615,156 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async sendForgotPasswordCode(dto: ForgotPasswordSendCodeDto) {
|
||||
if (!ForgotPasswordSendCodeDto.validateMobile(dto.mobile)) {
|
||||
throw new BadRequestException('Please enter a valid mobile number');
|
||||
}
|
||||
|
||||
const mobile = normalizeIranMobile(dto.mobile);
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { mobile },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'If this mobile number is registered, a verification code has been sent.',
|
||||
};
|
||||
}
|
||||
|
||||
const recentCode = await this.prisma.phoneVerificationCode.findFirst({
|
||||
where: {
|
||||
mobile,
|
||||
purpose: FORGOT_PASSWORD_PURPOSE,
|
||||
createdAt: { gt: new Date(Date.now() - SEND_CODE_COOLDOWN_MS) },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (recentCode) {
|
||||
throw new BadRequestException('Please wait before requesting another code');
|
||||
}
|
||||
|
||||
const code = this.generateVerificationCode();
|
||||
const codeHash = this.hashVerificationCode(code);
|
||||
|
||||
await this.prisma.phoneVerificationCode.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
mobile,
|
||||
codeHash,
|
||||
purpose: FORGOT_PASSWORD_PURPOSE,
|
||||
expiresAt: new Date(Date.now() + VERIFICATION_CODE_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
await this.smsService.sendVerificationCode(mobile, code);
|
||||
|
||||
if (this.configService.get<string>('NODE_ENV') === 'development') {
|
||||
console.log(`[dev] forgot-password code for ${mobile}: ${code}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'If this mobile number is registered, a verification code has been sent.',
|
||||
};
|
||||
}
|
||||
|
||||
async verifyForgotPasswordCode(dto: ForgotPasswordVerifyDto) {
|
||||
if (!ForgotPasswordSendCodeDto.validateMobile(dto.mobile)) {
|
||||
throw new BadRequestException('Please enter a valid mobile number');
|
||||
}
|
||||
|
||||
const mobile = normalizeIranMobile(dto.mobile);
|
||||
const code = dto.code.trim();
|
||||
|
||||
const verification = await this.prisma.phoneVerificationCode.findFirst({
|
||||
where: {
|
||||
mobile,
|
||||
purpose: FORGOT_PASSWORD_PURPOSE,
|
||||
verifiedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!verification || !this.isVerificationCodeValid(code, verification.codeHash)) {
|
||||
throw new UnauthorizedException('Invalid or expired verification code');
|
||||
}
|
||||
|
||||
await this.prisma.phoneVerificationCode.update({
|
||||
where: { id: verification.id },
|
||||
data: { verifiedAt: new Date() },
|
||||
});
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { mobile },
|
||||
include: {
|
||||
memberships: {
|
||||
include: {
|
||||
organization: {
|
||||
include: {
|
||||
type: true,
|
||||
plan: true,
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid or expired verification code');
|
||||
}
|
||||
|
||||
const loginResult = await this.login(
|
||||
{ email: user.email, password: '' } as LoginDto,
|
||||
user,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: loginResult.data.accessToken,
|
||||
refreshToken: loginResult.data.refreshToken,
|
||||
user: loginResult.data.user,
|
||||
organizations: loginResult.data.organizations,
|
||||
passwordResetVerified: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async hasRecentPasswordResetVerification(userId: string): Promise<boolean> {
|
||||
const recent = await this.prisma.phoneVerificationCode.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
purpose: FORGOT_PASSWORD_PURPOSE,
|
||||
verifiedAt: { gt: new Date(Date.now() - PASSWORD_RESET_WINDOW_MS) },
|
||||
},
|
||||
orderBy: { verifiedAt: 'desc' },
|
||||
});
|
||||
|
||||
return Boolean(recent);
|
||||
}
|
||||
|
||||
private generateVerificationCode(): string {
|
||||
return String(Math.floor(10000 + Math.random() * 90000));
|
||||
}
|
||||
|
||||
private hashVerificationCode(code: string): string {
|
||||
return crypto.createHash('sha256').update(code).digest('hex');
|
||||
}
|
||||
|
||||
private isVerificationCodeValid(code: string, codeHash: string): boolean {
|
||||
return this.hashVerificationCode(code.trim()) === codeHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active sessions for a user
|
||||
* @param userId - User ID
|
||||
@@ -963,12 +1151,14 @@ export class AuthService {
|
||||
email: string;
|
||||
name: string;
|
||||
language?: string | null;
|
||||
mobile?: string | null;
|
||||
}) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
language: user.language ?? 'en',
|
||||
mobile: user.mobile ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
11
backend/src/modules/auth/dto/change-password.dto.ts
Normal file
11
backend/src/modules/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currentPassword?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword: string;
|
||||
}
|
||||
22
backend/src/modules/auth/dto/forgot-password.dto.ts
Normal file
22
backend/src/modules/auth/dto/forgot-password.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { IsString, Matches, Length } from 'class-validator';
|
||||
import { isValidIranMobile } from '../../../common/utils/mobile.util';
|
||||
|
||||
export class ForgotPasswordSendCodeDto {
|
||||
@IsString()
|
||||
@Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' })
|
||||
mobile: string;
|
||||
|
||||
static validateMobile(mobile: string): boolean {
|
||||
return isValidIranMobile(mobile);
|
||||
}
|
||||
}
|
||||
|
||||
export class ForgotPasswordVerifyDto {
|
||||
@IsString()
|
||||
@Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' })
|
||||
mobile: string;
|
||||
|
||||
@IsString()
|
||||
@Length(5, 6)
|
||||
code: string;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// backend/src/modules/auth/dto/login.dto.ts
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginDto {
|
||||
@@ -20,4 +20,13 @@ export class LoginDto {
|
||||
@IsString()
|
||||
@MinLength(6, { message: 'Password must be at least 6 characters long' })
|
||||
password: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Keep the user signed in for 30 days on this device',
|
||||
required: false,
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { IsEmail, IsString, MinLength, IsEnum } from 'class-validator';
|
||||
import { IsEmail, IsString, MinLength, IsEnum, Matches } from 'class-validator';
|
||||
import { isValidIranMobile } from '../../../common/utils/mobile.util';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' })
|
||||
mobile: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
@@ -19,4 +24,8 @@ export class RegisterDto {
|
||||
|
||||
@IsEnum(['CLINIC', 'LAB'])
|
||||
organizationType: 'CLINIC' | 'LAB';
|
||||
|
||||
static isValidMobile(mobile: string): boolean {
|
||||
return isValidIranMobile(mobile);
|
||||
}
|
||||
}
|
||||
@@ -449,7 +449,7 @@ export class CasesService {
|
||||
details: Array<{ detail: { treatmentType: string } }>;
|
||||
tasks: Array<{ id: string; status: LabTaskStatus }>;
|
||||
}) {
|
||||
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
|
||||
const treatmentType = lc.details[0]?.detail.treatmentType ?? null;
|
||||
const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length;
|
||||
|
||||
return {
|
||||
@@ -462,7 +462,7 @@ export class CasesService {
|
||||
lastName: lc.treatment.patient.lastName,
|
||||
mobile: lc.treatment.patient.mobile,
|
||||
},
|
||||
treatmentTypes,
|
||||
treatmentType,
|
||||
taskProgress: {
|
||||
completed: completedTasks,
|
||||
total: lc.tasks.length,
|
||||
@@ -475,7 +475,8 @@ export class CasesService {
|
||||
localeInput?: string | null,
|
||||
) {
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
|
||||
const treatmentType = lc.details[0]?.detail.treatmentType ?? null;
|
||||
const link = lc.details[0];
|
||||
const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))];
|
||||
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
||||
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||
@@ -491,13 +492,15 @@ export class CasesService {
|
||||
clinic: lc.treatment.organization,
|
||||
patient: lc.treatment.patient,
|
||||
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
|
||||
treatmentTypes,
|
||||
details: lc.details.map((link) => ({
|
||||
id: link.detail.id,
|
||||
treatmentType: link.detail.treatmentType,
|
||||
teeth: normalizeTeeth(link.detail.teeth),
|
||||
comment: link.detail.comment,
|
||||
})),
|
||||
treatmentType,
|
||||
detail: link
|
||||
? {
|
||||
id: link.detail.id,
|
||||
treatmentType: link.detail.treatmentType,
|
||||
teeth: normalizeTeeth(link.detail.teeth),
|
||||
comment: link.detail.comment,
|
||||
}
|
||||
: null,
|
||||
toothProsthesis: lc.toothProsthesis.map((row) => ({
|
||||
treatmentDetailId: row.treatmentDetailId,
|
||||
tooth: row.tooth,
|
||||
|
||||
8
backend/src/modules/sms/sms.module.ts
Normal file
8
backend/src/modules/sms/sms.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
@Module({
|
||||
providers: [SmsService],
|
||||
exports: [SmsService],
|
||||
})
|
||||
export class SmsModule {}
|
||||
63
backend/src/modules/sms/sms.service.ts
Normal file
63
backend/src/modules/sms/sms.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
interface SmsIrVerifyResponse {
|
||||
status: number;
|
||||
message: string;
|
||||
data?: {
|
||||
messageId: number;
|
||||
cost: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async sendVerificationCode(mobile: string, code: string): Promise<void> {
|
||||
const apiKey = this.configService.get<string>('sms.apiKey');
|
||||
const templateId = this.configService.get<number>('sms.templateId');
|
||||
|
||||
if (!apiKey) {
|
||||
this.logger.warn(`SMS_IR_API_KEY not set — verification code for ${mobile}: ${code}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch('https://api.sms.ir/v1/send/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/plain',
|
||||
'x-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mobile,
|
||||
templateId,
|
||||
parameters: [{ name: 'Code', value: code }],
|
||||
}),
|
||||
});
|
||||
console.log('request', templateId , apiKey,mobile, code);
|
||||
console.log('response', response);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('sms.ir request failed', error);
|
||||
throw new InternalServerErrorException('Failed to send verification code');
|
||||
}
|
||||
|
||||
let payload: SmsIrVerifyResponse;
|
||||
try {
|
||||
payload = (await response.json()) as SmsIrVerifyResponse;
|
||||
} catch {
|
||||
throw new InternalServerErrorException('Invalid response from SMS provider');
|
||||
}
|
||||
|
||||
if (!response.ok || payload.status !== 1) {
|
||||
this.logger.error(`sms.ir error: ${payload.message}`);
|
||||
throw new InternalServerErrorException('Failed to send verification code');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,10 +71,8 @@ export class SaveLabCaseDto {
|
||||
@IsUUID()
|
||||
destinationOrganizationId?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsUUID(undefined, { each: true })
|
||||
treatmentDetailIds: string[];
|
||||
@IsUUID()
|
||||
treatmentDetailId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
|
||||
@@ -329,7 +329,7 @@ export class TreatmentsService {
|
||||
throw new NotFoundException('Save treatment details before creating lab cases');
|
||||
}
|
||||
|
||||
const detailIds = dto.labCases.flatMap((lc) => lc.treatmentDetailIds);
|
||||
const detailIds = dto.labCases.map((lc) => lc.treatmentDetailId);
|
||||
const uniqueDetailIds = new Set(detailIds);
|
||||
if (uniqueDetailIds.size !== detailIds.length) {
|
||||
throw new BadRequestException('Each treatment detail can belong to only one lab case');
|
||||
@@ -356,9 +356,9 @@ export class TreatmentsService {
|
||||
}
|
||||
|
||||
for (const row of lc.toothProsthesis ?? []) {
|
||||
if (!lc.treatmentDetailIds.includes(row.treatmentDetailId)) {
|
||||
if (lc.treatmentDetailId !== row.treatmentDetailId) {
|
||||
throw new BadRequestException(
|
||||
'Tooth prosthesis must reference a detail included in this lab case',
|
||||
'Tooth prosthesis must reference the lab case treatment detail',
|
||||
);
|
||||
}
|
||||
const detail = detailById.get(row.treatmentDetailId);
|
||||
@@ -416,11 +416,11 @@ export class TreatmentsService {
|
||||
});
|
||||
|
||||
await tx.labCaseDetail.deleteMany({ where: { labCaseId: row.id } });
|
||||
await tx.labCaseDetail.createMany({
|
||||
data: lc.treatmentDetailIds.map((treatmentDetailId) => ({
|
||||
await tx.labCaseDetail.create({
|
||||
data: {
|
||||
labCaseId: row.id,
|
||||
treatmentDetailId,
|
||||
})),
|
||||
treatmentDetailId: lc.treatmentDetailId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.labCaseToothProsthesis.deleteMany({ where: { labCaseId: row.id } });
|
||||
@@ -441,7 +441,7 @@ export class TreatmentsService {
|
||||
const validAttachments = await tx.treatmentDetailAttachment.findMany({
|
||||
where: {
|
||||
id: { in: attachmentIds },
|
||||
detailId: { in: lc.treatmentDetailIds },
|
||||
detailId: lc.treatmentDetailId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
@@ -502,7 +502,11 @@ export class TreatmentsService {
|
||||
}
|
||||
|
||||
if (labCase.details.length === 0) {
|
||||
throw new BadRequestException('Lab case must include at least one treatment detail');
|
||||
throw new BadRequestException('Lab case must include a treatment detail');
|
||||
}
|
||||
|
||||
if (labCase.details.length > 1) {
|
||||
throw new BadRequestException('Lab case can include only one treatment detail');
|
||||
}
|
||||
|
||||
assertCompleteToothProsthesisMap(labCase);
|
||||
@@ -811,13 +815,15 @@ export class TreatmentsService {
|
||||
clientId: lc.clientKey ?? lc.id,
|
||||
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||
treatmentDetailIds: lc.details?.map((d) => d.treatmentDetailId) ?? [],
|
||||
details: (lc.details ?? []).map((d) => ({
|
||||
id: d.detail?.id ?? d.treatmentDetailId,
|
||||
clientId: d.detail?.clientKey ?? d.treatmentDetailId,
|
||||
treatmentType: d.detail?.treatmentType ?? '',
|
||||
teeth: d.detail ? normalizeTeeth(d.detail.teeth) : [],
|
||||
})),
|
||||
treatmentDetailId: lc.details?.[0]?.treatmentDetailId ?? null,
|
||||
detail: lc.details?.[0]
|
||||
? {
|
||||
id: lc.details[0].detail?.id ?? lc.details[0].treatmentDetailId,
|
||||
clientId: lc.details[0].detail?.clientKey ?? lc.details[0].treatmentDetailId,
|
||||
treatmentType: lc.details[0].detail?.treatmentType ?? '',
|
||||
teeth: lc.details[0].detail ? normalizeTeeth(lc.details[0].detail.teeth) : [],
|
||||
}
|
||||
: null,
|
||||
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
|
||||
treatmentDetailId: tp.treatmentDetailId,
|
||||
tooth: tp.tooth,
|
||||
|
||||
Reference in New Issue
Block a user