- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
158 lines
5.8 KiB
TypeScript
158 lines
5.8 KiB
TypeScript
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: 'Внутренняя ошибка сервера' });
|
||
}
|
||
}
|
||
);
|
||
}
|