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; }; function zodToComponent(schema: z.ZodType, name: string): Record { const json = zodToJsonSchema(schema, { name, errorMessages: false }) as JsonSchema; const defs = (json.definitions ?? {}) as Record; const def: JsonSchema = defs[name] ?? json; const { $schema: _s, definitions: _d, ...clean } = def; return clean as Record; } 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 = ` iistwin API Docs
`; // ─── 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); }); }