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,157 @@
import { Router } from "express";
import crypto from 'crypto';
import { storage } from "../storage";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { validateRequest } from "../middleware/validation.middleware";
import { createInvitationSchema } from "@shared/schema";
import { emailService } from "../services/email.service";
export function registerInvitationRoutes(router: Router): void {
// Get invitation info by token (public - no auth required)
router.get('/api/invitations/:token', async (req, res) => {
try {
const { token } = req.params;
const invitation = await storage.getInvitationByToken(token);
if (!invitation) {
return res.status(404).json({ success: false, error: 'Приглашение не найдено' });
}
if (invitation.usedAt) {
return res.status(400).json({ success: false, error: 'Приглашение уже использовано' });
}
if (new Date(invitation.expiresAt) < new Date()) {
return res.status(400).json({ success: false, error: 'Срок действия приглашения истёк' });
}
const organization = await storage.getOrganization(invitation.organizationId);
res.json({
success: true,
invitation: {
email: invitation.email,
organizationName: organization?.displayName || organization?.name,
}
});
} catch (error) {
console.error('Get invitation error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// Create invitation (admin only)
router.post('/api/invitations',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
validateRequest(createInvitationSchema),
async (req: AuthenticatedRequest, res) => {
try {
const { email } = req.body;
const existingUser = await storage.getUserByEmail(email, req.organizationId!);
if (existingUser) {
return res.status(400).json({
success: false,
error: 'Пользователь с таким email уже существует в организации'
});
}
const existingInvitations = await storage.getInvitationsByOrganization(req.organizationId!);
const existingInvitation = existingInvitations.find(
inv => inv.email === email && !inv.usedAt && new Date(inv.expiresAt) > new Date()
);
if (existingInvitation) {
return res.status(400).json({ success: false, error: 'Приглашение для этого email уже существует' });
}
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
const invitation = await storage.createInvitation({
organizationId: req.organizationId!,
email,
token,
createdBy: req.user!.id,
expiresAt,
});
const organization = await storage.getOrganization(req.organizationId!);
const inviter = await storage.getUser(req.user!.id);
const baseUrl = process.env.BASE_URL || 'https://iistwin.ru';
const inviteLink = `${baseUrl}/invite/${token}`;
const inviterName = inviter ? `${inviter.firstName} ${inviter.middleName || ''} ${inviter.lastName}`.trim() : 'Администратор';
await emailService.sendInvitationLinkEmail(
email,
inviterName,
organization?.displayName || organization?.name || 'Организация',
inviteLink
);
res.status(201).json({
success: true,
invitation: {
id: invitation.id,
email: invitation.email,
token: invitation.token,
expiresAt: invitation.expiresAt,
inviteLink
}
});
} catch (error) {
console.error('Create invitation error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
// Get all invitations for organization (admin only)
router.get('/api/invitations',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const invitationsData = await storage.getInvitationsByOrganization(req.organizationId!);
const baseUrl = process.env.BASE_URL || 'https://iistwin.ru';
const invitations = invitationsData.map(inv => ({
id: inv.id,
email: inv.email,
token: inv.token,
createdAt: inv.createdAt,
expiresAt: inv.expiresAt,
usedAt: inv.usedAt,
inviteLink: `${baseUrl}/invite/${inv.token}`,
isExpired: new Date(inv.expiresAt) < new Date(),
isUsed: !!inv.usedAt
}));
res.json({ success: true, invitations });
} catch (error) {
console.error('Get invitations error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
// Delete invitation (admin only)
router.delete('/api/invitations/:id',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const id = parseInt(req.params.id);
await storage.deleteInvitation(id, req.organizationId!);
res.json({ success: true, message: 'Приглашение удалено' });
} catch (error) {
console.error('Delete invitation error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
}