fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
284
server/routes/org-connections.routes.ts
Normal file
284
server/routes/org-connections.routes.ts
Normal 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;
|
||||
Reference in New Issue
Block a user