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,284 @@
import { Router } from "express";
import { z } from "zod";
import crypto from "crypto";
import { db } from "../db";
import { orgConnections, organizations, users } from "@shared/schema";
import { eq, and, or, inArray } from "drizzle-orm";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
const router = Router();
// ── GET /api/org-connections/active — публичный список активных подключений (без токенов) ──
// Нужен всем пользователям для создания кросс-орг чатов
router.get("/api/org-connections/active",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const rows = await db
.select()
.from(orgConnections)
.where(and(
eq(orgConnections.status, "active"),
or(
eq(orgConnections.initiatorOrgId, orgId),
eq(orgConnections.partnerOrgId, orgId),
)
));
const orgIds = Array.from(new Set([
...rows.map(r => r.initiatorOrgId),
...rows.filter(r => r.partnerOrgId).map(r => r.partnerOrgId!),
]));
const orgs = orgIds.length
? await db
.select({ id: organizations.id, displayName: organizations.displayName })
.from(organizations)
.where(inArray(organizations.id, orgIds))
: [];
const orgMap = new Map(orgs.map(o => [o.id, o]));
const enriched = rows.map(r => ({
id: r.id,
status: r.status,
isInitiator: r.initiatorOrgId === orgId,
initiatorOrg: orgMap.get(r.initiatorOrgId) ?? null,
partnerOrg: r.partnerOrgId ? (orgMap.get(r.partnerOrgId) ?? null) : null,
acceptedAt: r.acceptedAt,
}));
res.json({ success: true, connections: enriched });
} catch (err) {
console.error("get active org-connections error:", err);
res.status(500).json({ success: false, error: "Ошибка при загрузке подключений" });
}
}
);
// ── GET /api/org-connections — список подключений моей организации (admin only) ──
router.get("/api/org-connections",
authenticateToken,
tenantIsolation,
requirePermission('settings.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const rows = await db
.select()
.from(orgConnections)
.where(or(
eq(orgConnections.initiatorOrgId, orgId),
eq(orgConnections.partnerOrgId, orgId),
));
// Enrich with org names
const orgIds = Array.from(new Set([
...rows.map(r => r.initiatorOrgId),
...rows.filter(r => r.partnerOrgId).map(r => r.partnerOrgId!),
]));
const orgs = orgIds.length
? await db.select({ id: organizations.id, displayName: organizations.displayName, name: organizations.name })
.from(organizations)
.where(inArray(organizations.id, orgIds))
: [];
const orgMap = new Map(orgs.map(o => [o.id, o]));
const enriched = rows.map(r => ({
id: r.id,
initiatorOrgId: r.initiatorOrgId,
partnerOrgId: r.partnerOrgId,
status: r.status,
// Expose token only to initiator for pending invites; otherwise mask it
token: r.initiatorOrgId === orgId && r.status === "pending" ? r.token : undefined,
createdAt: r.createdAt,
acceptedAt: r.acceptedAt,
initiatorOrg: orgMap.get(r.initiatorOrgId) ?? null,
partnerOrg: r.partnerOrgId ? (orgMap.get(r.partnerOrgId) ?? null) : null,
isInitiator: r.initiatorOrgId === orgId,
}));
res.json({ success: true, connections: enriched });
} catch (err) {
console.error("get org-connections error:", err);
res.status(500).json({ success: false, error: "Ошибка при загрузке подключений" });
}
}
);
// ── POST /api/org-connections/invite — создать токен приглашения ─────────────
router.post("/api/org-connections/invite",
authenticateToken,
tenantIsolation,
requirePermission('settings.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const token = crypto.randomBytes(32).toString("hex");
const [conn] = await db
.insert(orgConnections)
.values({ initiatorOrgId: orgId, status: "pending", token })
.returning();
res.status(201).json({ success: true, connection: conn, token });
} catch (err) {
console.error("invite org error:", err);
res.status(500).json({ success: false, error: "Ошибка при создании приглашения" });
}
}
);
// ── POST /api/org-connections/accept — принять приглашение по токену ─────────
const acceptSchema = z.object({ token: z.string().min(1) });
router.post("/api/org-connections/accept",
authenticateToken,
tenantIsolation,
requirePermission('settings.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const parsed = acceptSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ success: false, error: "Укажите токен приглашения" });
}
const { token } = parsed.data;
const [conn] = await db
.select()
.from(orgConnections)
.where(eq(orgConnections.token, token));
if (!conn) {
return res.status(404).json({ success: false, error: "Приглашение не найдено" });
}
if (conn.status !== "pending") {
return res.status(400).json({ success: false, error: "Приглашение уже использовано или отозвано" });
}
if (conn.initiatorOrgId === orgId) {
return res.status(400).json({ success: false, error: "Нельзя принять собственное приглашение" });
}
// Атомарное обновление: WHERE id=? AND status='pending'
// предотвращает race-condition при параллельных accepts
const [updated] = await db
.update(orgConnections)
.set({ partnerOrgId: orgId, status: "active", acceptedAt: new Date() })
.where(and(eq(orgConnections.id, conn.id), eq(orgConnections.status, "pending")))
.returning();
if (!updated) {
return res.status(409).json({ success: false, error: "Приглашение уже принято другим запросом" });
}
// Enrich with org info
const orgs = await db
.select({ id: organizations.id, displayName: organizations.displayName, name: organizations.name })
.from(organizations)
.where(inArray(organizations.id, [updated.initiatorOrgId, orgId]));
const orgMap = new Map(orgs.map(o => [o.id, o]));
res.json({
success: true,
connection: {
...updated,
initiatorOrg: orgMap.get(updated.initiatorOrgId) ?? null,
partnerOrg: orgMap.get(orgId) ?? null,
isInitiator: false,
},
});
} catch (err) {
console.error("accept org-connection error:", err);
res.status(500).json({ success: false, error: "Ошибка при принятии приглашения" });
}
}
);
// ── DELETE /api/org-connections/:id — отозвать/удалить подключение ───────────
router.delete("/api/org-connections/:id",
authenticateToken,
tenantIsolation,
requirePermission('settings.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const connId = parseInt(req.params.id);
if (isNaN(connId)) return res.status(400).json({ success: false, error: "Некорректный ID" });
const [conn] = await db
.select()
.from(orgConnections)
.where(eq(orgConnections.id, connId));
if (!conn) return res.status(404).json({ success: false, error: "Подключение не найдено" });
if (conn.initiatorOrgId !== orgId && conn.partnerOrgId !== orgId) {
return res.status(403).json({ success: false, error: "Нет доступа к этому подключению" });
}
await db
.update(orgConnections)
.set({ status: "revoked" })
.where(eq(orgConnections.id, connId));
res.json({ success: true });
} catch (err) {
console.error("delete org-connection error:", err);
res.status(500).json({ success: false, error: "Ошибка при отзыве подключения" });
}
}
);
// ── GET /api/org-connections/:id/users — пользователи партнёрской орг ────────
router.get("/api/org-connections/:id/users",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const connId = parseInt(req.params.id);
if (isNaN(connId)) return res.status(400).json({ success: false, error: "Некорректный ID" });
const [conn] = await db
.select()
.from(orgConnections)
.where(and(
eq(orgConnections.id, connId),
eq(orgConnections.status, "active"),
));
if (!conn) return res.status(404).json({ success: false, error: "Активное подключение не найдено" });
if (conn.initiatorOrgId !== orgId && conn.partnerOrgId !== orgId) {
return res.status(403).json({ success: false, error: "Нет доступа к этому подключению" });
}
const partnerOrgId = conn.initiatorOrgId === orgId ? conn.partnerOrgId! : conn.initiatorOrgId;
// Возвращаем только имя и должность — без email
const partnerUsers = await db
.select({
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
position: users.position,
organizationId: users.organizationId,
})
.from(users)
.where(and(
eq(users.organizationId, partnerOrgId),
eq(users.isActive, true),
));
res.json({ success: true, users: partnerUsers, partnerOrgId });
} catch (err) {
console.error("get org-connection users error:", err);
res.status(500).json({ success: false, error: "Ошибка при загрузке пользователей" });
}
}
);
export default router;