fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,244 @@
import { MailService } from '@sendgrid/mail';
if (!process.env.SENDGRID_API_KEY) {
console.warn("SENDGRID_API_KEY environment variable not set. Email functionality will be disabled.");
}
const mailService = new MailService();
if (process.env.SENDGRID_API_KEY) {
mailService.setApiKey(process.env.SENDGRID_API_KEY);
}
const FROM_EMAIL = process.env.FROM_EMAIL || 'noreply@iistwin.ru';
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5000';
export class EmailService {
async sendVerificationEmail(
email: string,
firstName: string,
verificationToken: string,
organizationName: string
): Promise<boolean> {
if (!process.env.SENDGRID_API_KEY) {
console.log(`Mock verification email sent to ${email}`);
return true;
}
try {
const verificationUrl = `${FRONTEND_URL}/verify-email?token=${verificationToken}`;
await mailService.send({
to: email,
from: FROM_EMAIL,
subject: `Подтверждение email в ${organizationName}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1976d2;">Добро пожаловать в ${organizationName}!</h2>
<p>Здравствуйте, ${firstName}!</p>
<p>Для завершения регистрации перейдите по ссылке:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${verificationUrl}"
style="background: #1976d2; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">
Подтвердить email
</a>
</div>
<p style="color: #666;">Ссылка действительна в течение 24 часов.</p>
<p style="color: #666; font-size: 12px;">
Если вы не регистрировались в нашей системе, просто проигнорируйте это письмо.
</p>
</div>
`
});
return true;
} catch (error) {
console.error('SendGrid verification email error:', error);
return false;
}
}
async sendInvitationEmail(
email: string,
firstName: string,
inviterName: string,
organizationName: string,
tempPassword: string
): Promise<boolean> {
if (!process.env.SENDGRID_API_KEY) {
console.log(`Mock invitation email sent to ${email}`);
return true;
}
try {
const loginUrl = `${FRONTEND_URL}/login`;
await mailService.send({
to: email,
from: FROM_EMAIL,
subject: `Приглашение в ${organizationName}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1976d2;">Вы приглашены в ${organizationName}</h2>
<p>Здравствуйте, ${firstName}!</p>
<p>${inviterName} пригласил вас присоединиться к команде.</p>
<div style="background: #f5f5f5; padding: 20px; border-radius: 5px; margin: 20px 0;">
<p><strong>Ваши данные для входа:</strong></p>
<p>Email: ${email}</p>
<p>Временный пароль: <code style="background: #e0e0e0; padding: 2px 4px; border-radius: 3px;">${tempPassword}</code></p>
</div>
<p style="color: #d32f2f;"><strong>Важно:</strong> Обязательно смените пароль после первого входа.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${loginUrl}"
style="background: #1976d2; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">
Войти в систему
</a>
</div>
<p style="color: #666; font-size: 12px;">
Если вы не ожидали этого приглашения, свяжитесь с администратором ${organizationName}.
</p>
</div>
`
});
return true;
} catch (error) {
console.error('SendGrid invitation email error:', error);
return false;
}
}
async sendInvitationLinkEmail(
email: string,
inviterName: string,
organizationName: string,
inviteLink: string
): Promise<boolean> {
if (!process.env.SENDGRID_API_KEY) {
console.log(`Mock invitation link email sent to ${email}, link: ${inviteLink}`);
return true;
}
try {
await mailService.send({
to: email,
from: FROM_EMAIL,
subject: `Приглашение в ${organizationName}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1976d2;">Вы приглашены в ${organizationName}</h2>
<p>Здравствуйте!</p>
<p>${inviterName} пригласил вас присоединиться к команде.</p>
<p>Для завершения регистрации перейдите по ссылке:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${inviteLink}"
style="background: #1976d2; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">
Принять приглашение
</a>
</div>
<p style="color: #666;">Приглашение действительно в течение 7 дней.</p>
<p style="color: #666; font-size: 12px;">
Если вы не ожидали этого приглашения, просто проигнорируйте это письмо.
</p>
</div>
`
});
return true;
} catch (error) {
console.error('SendGrid invitation link email error:', error);
return false;
}
}
async sendPasswordResetEmail(
email: string,
firstName: string,
resetToken: string,
organizationName: string,
returnTo?: string
): Promise<boolean> {
if (!process.env.SENDGRID_API_KEY) {
const returnToParam = returnTo ? `&returnTo=${encodeURIComponent(returnTo)}` : '';
console.log(`[DEV] Password reset link for ${email}: ${FRONTEND_URL}/reset-password?token=${resetToken}${returnToParam}`);
return true;
}
try {
const returnToParam = returnTo ? `&returnTo=${encodeURIComponent(returnTo)}` : '';
const resetUrl = `${FRONTEND_URL}/reset-password?token=${resetToken}${returnToParam}`;
await mailService.send({
to: email,
from: FROM_EMAIL,
subject: `Восстановление пароля - ${organizationName}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1976d2;">Восстановление пароля</h2>
<p>Здравствуйте, ${firstName}!</p>
<p>Вы запросили восстановление пароля для вашей учетной записи в ${organizationName}.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${resetUrl}"
style="background: #1976d2; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">
Восстановить пароль
</a>
</div>
<p style="color: #666;">Ссылка действительна в течение 1 часа.</p>
<p style="color: #666; font-size: 12px;">
Если вы не запрашивали восстановление пароля, просто проигнорируйте это письмо.
</p>
</div>
`
});
return true;
} catch (error) {
console.error('SendGrid password reset email error:', error);
return false;
}
}
async sendSuperAdminPasswordResetEmail(
email: string,
name: string,
resetToken: string
): Promise<boolean> {
if (!process.env.SENDGRID_API_KEY) {
console.log(`[DEV] Superadmin password reset link for ${email}: ${FRONTEND_URL}/superadmin/reset-password?token=${resetToken}`);
return true;
}
try {
const resetUrl = `${FRONTEND_URL}/superadmin/reset-password?token=${resetToken}`;
await mailService.send({
to: email,
from: FROM_EMAIL,
subject: 'Восстановление пароля суперадмина',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #7c3aed;">Восстановление пароля суперадмина</h2>
<p>Здравствуйте, ${name}!</p>
<p>Вы запросили сброс пароля для панели суперадмина.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${resetUrl}"
style="background: #7c3aed; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">
Задать новый пароль
</a>
</div>
<p style="color: #666;">Ссылка действительна в течение 1 часа.</p>
<p style="color: #666; font-size: 12px;">
Если вы не запрашивали восстановление пароля, просто проигнорируйте это письмо.
</p>
</div>
`
});
return true;
} catch (error) {
console.error('SendGrid superadmin password reset email error:', error);
return false;
}
}
}
export const emailService = new EmailService();