Files
iistwin/server/routes/org-connections.routes.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

285 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;