Merge branch 'master' into feature/cases

This commit is contained in:
2026-07-10 15:26:36 +03:30
68 changed files with 2497 additions and 604 deletions

View 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));
}

View File

@@ -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),
},
};
};

View File

@@ -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);
}
}

View File

@@ -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) => ({

View File

@@ -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,
};
}
}

View File

@@ -0,0 +1,11 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsOptional()
@IsString()
currentPassword?: string;
@IsString()
@MinLength(8)
newPassword: string;
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';
@Module({
providers: [SmsService],
exports: [SmsService],
})
export class SmsModule {}

View 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');
}
}
}

View File

@@ -71,10 +71,8 @@ export class SaveLabCaseDto {
@IsUUID()
destinationOrganizationId?: string;
@IsArray()
@ArrayMinSize(1)
@IsUUID(undefined, { each: true })
treatmentDetailIds: string[];
@IsUUID()
treatmentDetailId: string;
@IsOptional()
@IsArray()

View File

@@ -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,