Merge branch 'master' into feature/cases
This commit is contained in:
@@ -39,3 +39,8 @@ SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your_email@gmail.com
|
||||
SMTP_PASSWORD=your_app_password
|
||||
|
||||
# SMS (sms.ir — use Sandbox API key for development)
|
||||
# SMS_IR_API_KEY=4QKMiSU4Kh7tWPLCdRMV0QpDh8WgF33YkWRS18BcG3vf4QHi
|
||||
SMS_IR_API_KEY=lwbK7hxmjimNjFS4g5DWahh75EKCgJUfcUIinUQzfQXwXkSp
|
||||
SMS_IR_TEMPLATE_ID=123456
|
||||
|
||||
5
backend/.gitignore
vendored
5
backend/.gitignore
vendored
@@ -3,6 +3,11 @@
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Accidental tsc output next to Prisma sources (keep only .ts / schema / migrations)
|
||||
/prisma/*.js
|
||||
/prisma/*.d.ts
|
||||
/prisma/*.js.map
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
@@ -52,4 +52,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--", "docker-entrypoint.sh"]
|
||||
|
||||
CMD ["node", "dist/main"]
|
||||
CMD ["node", "dist/src/main.js"]
|
||||
|
||||
@@ -9,6 +9,13 @@ if [ "$NODE_ENV" = "production" ]; then
|
||||
echo "Running in PRODUCTION mode"
|
||||
echo "Running database migrations..."
|
||||
./node_modules/.bin/prisma migrate deploy
|
||||
if [ -f "dist/prisma/seed.js" ]; then
|
||||
echo "Seeding reference data (plans, org types, permissions)..."
|
||||
node dist/prisma/seed.js
|
||||
elif [ -f "prisma/seed.ts" ] || [ -f "prisma/seed.js" ]; then
|
||||
echo "Running database seed..."
|
||||
./node_modules/.bin/prisma db seed
|
||||
fi
|
||||
else
|
||||
echo "Running in DEVELOPMENT mode"
|
||||
echo "Syncing database schema..."
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"start:prod": "node dist/src/main.js",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "mobile" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_mobile_key" ON "users"("mobile");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "phone_verification_codes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"mobile" TEXT NOT NULL,
|
||||
"codeHash" TEXT NOT NULL,
|
||||
"purpose" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"verifiedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "phone_verification_codes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "phone_verification_codes_mobile_purpose_createdAt_idx" ON "phone_verification_codes"("mobile", "purpose", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "phone_verification_codes" ADD CONSTRAINT "phone_verification_codes_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Enforce one treatment detail per lab case (1:1 via join table).
|
||||
-- Keep the earliest-linked detail when duplicate rows exist for the same case.
|
||||
|
||||
DELETE FROM "lab_case_details" lcd
|
||||
WHERE lcd.ctid NOT IN (
|
||||
SELECT MIN(inner_lcd.ctid)
|
||||
FROM "lab_case_details" inner_lcd
|
||||
GROUP BY inner_lcd."labCaseId"
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "lab_case_details_labCaseId_key" ON "lab_case_details"("labCaseId");
|
||||
@@ -11,6 +11,7 @@ datasource db {
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
mobile String? @unique
|
||||
passwordHash String?
|
||||
googleId String? @unique
|
||||
facebookId String? @unique
|
||||
@@ -26,6 +27,7 @@ model User {
|
||||
statusChangedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskLastStatusChangedBy")
|
||||
labCaseTaskStatusEvents LabCaseTaskStatusEvent[]
|
||||
labCaseComments LabCaseComment[]
|
||||
phoneVerificationCodes PhoneVerificationCode[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -33,6 +35,22 @@ model User {
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model PhoneVerificationCode {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
mobile String
|
||||
codeHash String
|
||||
purpose String
|
||||
expiresAt DateTime
|
||||
verifiedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([mobile, purpose, createdAt])
|
||||
@@map("phone_verification_codes")
|
||||
}
|
||||
|
||||
model OrganizationType {
|
||||
id String @id @default(uuid())
|
||||
name String @unique // "CLINIC" or "LAB"
|
||||
@@ -222,7 +240,7 @@ model LabCaseAttachment {
|
||||
}
|
||||
|
||||
model LabCaseDetail {
|
||||
labCaseId String
|
||||
labCaseId String @unique
|
||||
treatmentDetailId String @unique
|
||||
|
||||
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -12,16 +12,15 @@ import {
|
||||
buildProsthesisStepCodes,
|
||||
} from './catalog-seed-data';
|
||||
|
||||
// Load environment variables from the correct path
|
||||
const envPath = path.join(__dirname, '..', '.env');
|
||||
console.log('Loading .env from:', envPath);
|
||||
config({ path: envPath });
|
||||
// Load .env when running locally; Docker injects DATABASE_URL via env_file.
|
||||
if (!process.env.DATABASE_URL) {
|
||||
const envPath = path.join(__dirname, '..', '.env');
|
||||
console.log('Loading .env from:', envPath);
|
||||
config({ path: envPath });
|
||||
}
|
||||
|
||||
// Verify DATABASE_URL is loaded
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('❌ DATABASE_URL is not set in environment');
|
||||
console.log('Current directory:', process.cwd());
|
||||
console.log('.env path:', envPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
|
||||
1
backend/tsconfig.build.tsbuildinfo
Normal file
1
backend/tsconfig.build.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@@ -12,6 +12,7 @@
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"jsx": "react",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
|
||||
Reference in New Issue
Block a user