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

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

341 lines
14 KiB
TypeScript

import swaggerJsdoc from "swagger-jsdoc";
import swaggerUi from "swagger-ui-express";
import { zodToJsonSchema } from "zod-to-json-schema";
import { z } from "zod";
import express from "express";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import type { Express, Request, Response } from "express";
import {
registerOrganizationSchema,
loginSchema,
insertFormSchema,
insertFormFieldSchema,
insertFormStatusSchema,
insertTaskSchema,
createTaskMessageSchema,
createInvitationSchema,
registerByInvitationSchema,
createSubscriptionSchema,
} from "../shared/schema";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ─── Zod → JSON Schema helpers ────────────────────────────────────────────────
type JsonSchema = {
[key: string]: unknown;
$schema?: string;
definitions?: Record<string, JsonSchema>;
};
function zodToComponent(schema: z.ZodType, name: string): Record<string, unknown> {
const json = zodToJsonSchema(schema, { name, errorMessages: false }) as JsonSchema;
const defs = (json.definitions ?? {}) as Record<string, JsonSchema>;
const def: JsonSchema = defs[name] ?? json;
const { $schema: _s, definitions: _d, ...clean } = def;
return clean as Record<string, unknown>;
}
const zodComponents = {
RegisterOrganizationInput: zodToComponent(registerOrganizationSchema, "RegisterOrganizationInput"),
LoginInput: zodToComponent(loginSchema, "LoginInput"),
CreateFormInput: zodToComponent(insertFormSchema, "CreateFormInput"),
CreateFormFieldInput: zodToComponent(insertFormFieldSchema, "CreateFormFieldInput"),
CreateFormStatusInput: zodToComponent(insertFormStatusSchema, "CreateFormStatusInput"),
CreateTaskInput: zodToComponent(insertTaskSchema, "CreateTaskInput"),
CreateTaskMessageInput: zodToComponent(createTaskMessageSchema, "CreateTaskMessageInput"),
CreateInvitationInput: zodToComponent(createInvitationSchema, "CreateInvitationInput"),
RegisterByInvitationInput: zodToComponent(registerByInvitationSchema, "RegisterByInvitationInput"),
CreateSubscriptionInput: zodToComponent(createSubscriptionSchema, "CreateSubscriptionInput"),
};
// ─── Static model schemas (response shapes) ───────────────────────────────────
const modelSchemas = {
Error: {
type: "object",
properties: {
error: { type: "string" },
success: { type: "boolean", example: false },
},
},
User: {
type: "object",
properties: {
id: { type: "integer" },
email: { type: "string", format: "email" },
firstName: { type: "string" },
lastName: { type: "string" },
fullName: { type: "string" },
position: { type: "string", nullable: true },
role: { type: "string", enum: ["admin", "user"] },
isActive: { type: "boolean" },
organizationId: { type: "integer" },
createdAt: { type: "string", format: "date-time" },
},
},
Form: {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string" },
description: { type: "string", nullable: true },
isActive: { type: "boolean" },
chatEnabled: { type: "boolean" },
chatLayout: { type: "string", enum: ["default", "left", "tab"] },
createdAt: { type: "string", format: "date-time" },
},
},
FormField: {
type: "object",
properties: {
id: { type: "integer" },
formId: { type: "integer" },
tabId: { type: "integer", nullable: true },
name: { type: "string" },
code: { type: "string" },
type: { type: "string" },
isRequired: { type: "boolean" },
position: { type: "integer" },
options: { type: "array", items: { type: "object" }, nullable: true },
},
},
FormStatus: {
type: "object",
properties: {
id: { type: "integer" },
formId: { type: "integer" },
name: { type: "string" },
color: { type: "string", example: "#e5e7eb" },
position: { type: "integer" },
isInitial: { type: "boolean" },
isFinal: { type: "boolean" },
},
},
Task: {
type: "object",
properties: {
id: { type: "integer" },
formId: { type: "integer" },
title: { type: "string" },
description: { type: "string", nullable: true },
currentStatusId: { type: "integer" },
assignedTo: { type: "integer", nullable: true },
createdBy: { type: "integer" },
depth: { type: "integer" },
parentTaskId: { type: "integer", nullable: true },
isCompleted: { type: "boolean" },
dueDate: { type: "string", format: "date-time", nullable: true },
createdAt: { type: "string", format: "date-time" },
},
},
TaskMessage: {
type: "object",
properties: {
id: { type: "integer" },
taskId: { type: "integer" },
authorId: { type: "integer", nullable: true },
message: { type: "string" },
messageType: { type: "string", enum: ["comment", "system", "status_change", "bot"] },
mentionedUserIds: { type: "array", items: { type: "integer" } },
createdAt: { type: "string", format: "date-time" },
},
},
Notification: {
type: "object",
properties: {
id: { type: "integer" },
type: { type: "string" },
title: { type: "string" },
message: { type: "string" },
isRead: { type: "boolean" },
taskId: { type: "integer", nullable: true },
createdAt: { type: "string", format: "date-time" },
},
},
Table: {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string" },
code: { type: "string" },
columns: { type: "array", items: { type: "object" } },
},
},
ApiKey: {
type: "object",
properties: {
id: { type: "integer" },
keyPrefix: { type: "string", example: "wf_abc123xy" },
label: { type: "string" },
createdBy: { type: "integer" },
createdAt: { type: "string", format: "date-time" },
lastUsedAt: { type: "string", format: "date-time", nullable: true },
isActive: { type: "boolean" },
},
},
RelatedTreeNode: {
type: "object",
description: "Узел дерева связанных задач. Поле `nodes` содержит дочерние узлы того же типа (рекурсивно, до 10 уровней).",
properties: {
id: { type: "integer", description: "ID задачи" },
title: { type: "string", description: "Название задачи" },
formId: { type: "integer", description: "ID формы, к которой относится задача" },
formName: { type: "string", description: "Название формы" },
statusName: { type: "string", nullable: true, description: "Название текущего статуса" },
statusColor: { type: "string", nullable: true, example: "#e5e7eb", description: "Цвет текущего статуса" },
isFinal: { type: "boolean", description: "Является ли текущий статус финальным" },
fieldName: { type: "string", description: "Имя поля связи или «Связь из чата» для ручных привязок" },
nodes: {
type: "array",
description: "Вложенные узлы (родители родителей или дети детей)",
items: { "$ref": "#/components/schemas/RelatedTreeNode" },
},
},
},
TokensResponse: {
type: "object",
properties: {
accessToken: { type: "string", description: "JWT access token (15 min)" },
refreshToken: { type: "string", description: "JWT refresh token (7 days)" },
expiresIn: { type: "integer", description: "Access token TTL in seconds" },
},
},
};
// ─── swagger-jsdoc configuration ──────────────────────────────────────────────
const swaggerDefinition = {
openapi: "3.0.3",
info: {
title: "iistwin API",
version: "1.0.0",
description:
"Мультитенантная платформа iistwin.\n\n" +
"Защищённые эндпоинты требуют JWT Bearer токен.\n" +
"Получить токен: `POST /api/auth/login` → `tokens.accessToken`.\n\n" +
"Tenant-изоляция обеспечивается автоматически — каждый пользователь видит только данные своей организации.",
},
servers: [{ url: "/", description: "Current server" }],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description: "JWT access token из /api/auth/login → tokens.accessToken",
},
},
schemas: {
...zodComponents,
...modelSchemas,
},
},
security: [{ bearerAuth: [] }],
tags: [
{ name: "Auth", description: "Аутентификация и управление сессиями" },
{ name: "Users", description: "Управление пользователями организации" },
{ name: "Invitations", description: "Приглашения в организацию" },
{ name: "Forms", description: "Формы, поля, статусы и переходы" },
{ name: "Tasks", description: "Задачи и подзадачи" },
{ name: "Messages", description: "Чат и сообщения задачи" },
{ name: "Notifications", description: "Уведомления и подписки" },
{ name: "Tables", description: "Справочники (data tables)" },
{ name: "Dashboard", description: "Панели и виджеты дашборда" },
{ name: "Devices", description: "Push-уведомления и устройства" },
{ name: "Bots", description: "Боты и интеграции (n8n)" },
{ name: "MCP", description: "API-ключи для MCP (AI-агенты)" },
],
};
const swaggerJsdocOptions: swaggerJsdoc.Options = {
definition: swaggerDefinition,
apis: ["./server/routes.ts"],
};
export const swaggerSpec = swaggerJsdoc(swaggerJsdocOptions);
// ─── Swagger UI options ───────────────────────────────────────────────────────
const swaggerUiOptions: swaggerUi.SwaggerUiOptions = {
customSiteTitle: "iistwin API Docs",
swaggerOptions: {
persistAuthorization: true,
filter: true,
tagsSorter: "alpha",
operationsSorter: "alpha",
},
};
// ─── Swagger UI HTML template ─────────────────────────────────────────────────
/**
* Custom Swagger UI page that:
* - Loads without auth (browser-friendly navigation)
* - Uses a requestInterceptor to auto-attach the JWT from localStorage
* (the app stores accessToken in localStorage on the same origin)
* - Points to /api/docs-json which IS protected by authenticateToken
*
* Result: logged-in users see the full spec immediately; logged-out users see
* a "Failed to fetch" spec error — spec content is never publicly served.
*/
const swaggerHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>iistwin API Docs</title>
<link rel="stylesheet" type="text/css" href="/api/docs/assets/swagger-ui.css">
<link rel="icon" type="image/png" href="/api/docs/assets/favicon-32x32.png" sizes="32x32">
</head>
<body>
<div id="swagger-ui"></div>
<script src="/api/docs/assets/swagger-ui-bundle.js"></script>
<script src="/api/docs/assets/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function () {
SwaggerUIBundle({
url: "/api/docs-json",
dom_id: "#swagger-ui",
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
layout: "StandaloneLayout",
persistAuthorization: true,
filter: true,
tagsSorter: "alpha",
operationsSorter: "alpha",
requestInterceptor: function (request) {
var token = localStorage.getItem("accessToken") || sessionStorage.getItem("accessToken");
if (token && !request.headers["Authorization"]) {
request.headers["Authorization"] = "Bearer " + token;
}
return request;
}
});
};
</script>
</body>
</html>`;
// ─── Mount ────────────────────────────────────────────────────────────────────
export function setupSwagger(app: Express): void {
// Swagger UI static assets (CSS/JS) — served without auth so browsers can load them.
const swaggerDistPath = join(__dirname, "../node_modules/swagger-ui-dist");
app.use("/api/docs/assets", express.static(swaggerDistPath, { index: false }));
// Swagger UI HTML — publicly accessible; requestInterceptor auto-attaches JWT from
// localStorage (same origin as the app), so logged-in users see the spec automatically.
app.get("/api/docs", (_req: Request, res: Response) => {
res.setHeader("Content-Type", "text/html");
res.send(swaggerHtml);
});
// Raw OpenAPI spec — publicly accessible (spec describes API structure, contains no user data).
// Individual API endpoints are still protected by their own auth middleware.
app.get("/api/docs-json", (_req: Request, res: Response) => {
res.setHeader("Cache-Control", "public, max-age=300");
res.json(swaggerSpec);
});
}