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