feat: add docx renderer service and stabilize AI drawer waits

Create a self-hosted DOCX template rendering microservice with API key auth, Docker deployment, and usage docs, and extend AI Drawer fallback waiting to avoid premature timeout errors during long n8n processing.
This commit is contained in:
Fedor
2026-03-26 13:00:14 +03:00
parent 2bb56342f4
commit aec27abeb0
23 changed files with 5884 additions and 2 deletions

View File

@@ -0,0 +1,4 @@
node_modules
npm-debug.log
.env
.git

View File

@@ -0,0 +1,15 @@
# App
NODE_ENV=production
PORT=3000
JSON_BODY_LIMIT=2mb
MAX_TEMPLATE_SIZE_MB=10
API_KEY=replace_with_strong_random_key
# S3
S3_ENDPOINT=https://s3.twcstorage.ru
S3_REGION=ru-1
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=true
S3_PUBLIC_BASE_URL=

3
docx-renderer/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.env
npm-debug.log*

View File

@@ -0,0 +1,236 @@
# DOCX Renderer: инструкция по использованию
Эта инструкция для сервиса:
- URL: `http://147.45.146.17:3015`
- Health: `GET /health`
- Рабочие методы: `POST /render`, `POST /render-and-save`
- Авторизация: API ключ обязателен для рабочих методов
## 1) Что делает сервис
Сервис берет DOCX-шаблон и данные JSON, подставляет значения в placeholder-ы формата `{{key}}` и возвращает готовый DOCX.
Поддерживаются два источника шаблона:
1. Загрузить шаблон файлом (`multipart/form-data`)
2. Прочитать шаблон по `templatePath` из S3
## 2) Авторизация (API key)
Для `POST /render` и `POST /render-and-save` передавайте ключ одним из способов:
- `x-api-key: <YOUR_API_KEY>`
- `Authorization: Bearer <YOUR_API_KEY>`
Проверка:
- без ключа: `401 API key is required`
- с неверным ключом: `401 Invalid API key`
## 3) Health-check
```bash
curl -s http://147.45.146.17:3015/health
```
Ответ:
```json
{
"ok": true
}
```
## 4) Шаблон и placeholder-ы
В DOCX используйте placeholder-ы вида:
- `{{current_date}}`
- `{{claim_number}}`
- `{{client_fio}}`
- и любые другие ключи из `data`
Важные моменты:
- список полей не захардкожен (generic-рендер)
- лишние поля в `data` игнорируются
- пустые значения не ломают рендер (подставляется пустая строка)
- многострочный текст поддержан (`linebreaks: true`)
## 5) Endpoint `POST /render`
### 5.1 Вариант A: шаблон файлом
```bash
curl -X POST "http://147.45.146.17:3015/render?download=1" \
-H "x-api-key: YOUR_API_KEY" \
-F "template=@./template.docx" \
-F 'data={"current_date":"26.03.2026","claim_number":"KP-009097","facts_block":"Строка 1\nСтрока 2"}' \
-F "outputFileName=pretrial_offer_KP-009097.docx" \
--output pretrial_offer_KP-009097.docx
```
Что важно:
- `template` — DOCX файл
- `data` — JSON строка
- `download=1` — вернуть бинарный DOCX attachment
### 5.2 Вариант B: шаблон из S3 по пути
```bash
curl -X POST "http://147.45.146.17:3015/render" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"templatePath": "claim_templates/pretrial_settlement_offer_v1.docx",
"data": {
"current_date": "26.03.2026",
"claim_number": "KP-009097",
"client_fio": "Константин Поздняков"
},
"outputFileName": "pretrial_offer_KP-009097.docx",
"saveToS3": false
}'
```
Ответ (JSON-режим):
```json
{
"success": true,
"fileName": "pretrial_offer_KP-009097.docx",
"size": 12345,
"mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"templateSource": "s3",
"templatePath": "claim_templates/pretrial_settlement_offer_v1.docx",
"savedToS3": false,
"bucket": null,
"path": null,
"url": null
}
```
### 5.3 Сохранение результата в S3 из `/render`
```bash
curl -X POST "http://147.45.146.17:3015/render" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"templatePath": "claim_templates/pretrial_settlement_offer_v1.docx",
"data": {
"claim_number": "KP-009097"
},
"saveToS3": true,
"outputPath": "generated/507222/pretrial_offer_KP-009097.docx"
}'
```
## 6) Endpoint `POST /render-and-save`
Этот endpoint всегда делает:
- рендер из S3-шаблона
- сохранение результата в S3
- JSON-ответ с метаданными
```bash
curl -X POST "http://147.45.146.17:3015/render-and-save" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"templatePath": "claim_templates/pretrial_settlement_offer_v1.docx",
"data": {
"current_date": "26.03.2026",
"claim_number": "KP-009097",
"client_fio": "Константин Поздняков"
},
"outputFileName": "pretrial_offer_KP-009097.docx",
"outputPath": "generated/507222/pretrial_offer_KP-009097.docx"
}'
```
Пример ответа:
```json
{
"success": true,
"fileName": "pretrial_offer_KP-009097.docx",
"path": "generated/507222/pretrial_offer_KP-009097.docx",
"bucket": "your-bucket",
"url": "https://...",
"size": 12345
}
```
## 7) Формат ошибок
Примеры:
```json
{
"success": false,
"error": "templatePath or template file is required"
}
```
```json
{
"success": false,
"error": "data must be a valid JSON object"
}
```
```json
{
"success": false,
"error": "Failed to render DOCX template",
"details": {
"id": "multi_error",
"explanation": "..."
}
}
```
## 8) ENV переменные
Основные:
- `PORT` (по умолчанию `3000`, наружу проброшен на `3015`)
- `API_KEY` (обязателен)
- `MAX_TEMPLATE_SIZE_MB` (по умолчанию `10`)
- `JSON_BODY_LIMIT` (по умолчанию `2mb`)
S3:
- `S3_ENDPOINT`
- `S3_REGION`
- `S3_BUCKET`
- `S3_ACCESS_KEY_ID`
- `S3_SECRET_ACCESS_KEY`
- `S3_FORCE_PATH_STYLE` (`true/false`)
- `S3_PUBLIC_BASE_URL` (опционально)
## 9) Типовые проблемы
1. `Missing required environment variable: S3_ENDPOINT`
Не заполнены S3 env-переменные.
2. `Template not found in S3`
Неверный `templatePath` или нет доступа к объекту.
3. `template file must have .docx extension`
В `multipart` передан не DOCX.
4. `Template file is too large`
Превышен лимит `MAX_TEMPLATE_SIZE_MB`.
## 10) Быстрый чек-лист перед интеграцией
1. Проверить `GET /health`
2. Проверить авторизацию (401 без ключа, 200/400 с ключом)
3. Проверить `render` через upload шаблона
4. Проверить `render-and-save` через `templatePath`
5. Проверить URL/путь сохранения в S3

View File

@@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
EXPOSE 3000
CMD ["npm", "start"]

232
docx-renderer/app/README.md Normal file
View File

@@ -0,0 +1,232 @@
# DOCX Renderer Service
Self-hosted HTTP REST API для генерации DOCX по шаблону:
- вход: DOCX template + JSON data
- рендер: `docxtemplater` + `pizzip`
- выход: готовый DOCX (download) или JSON-метаданные
- опционально: чтение шаблона и сохранение результата в S3-совместимое хранилище
## Стек
- Node.js
- Express
- docxtemplater
- pizzip
- multer
- AWS SDK v3 (`@aws-sdk/client-s3`)
- Docker
## Структура
```text
app/
src/
routes/
services/
utils/
middleware/
app.js
server.js
package.json
Dockerfile
docker-compose.yml
.env.example
README.md
```
## Запуск локально
Требование: `Node.js >= 20`.
```bash
cd docx-renderer/app
cp .env.example .env
npm install
npm start
```
Сервис поднимется на `PORT` (по умолчанию `3000`).
## Запуск через Docker
```bash
cd docx-renderer/app
cp .env.example .env
docker compose up -d --build
```
По умолчанию в compose внешний порт: `3015` (внутри контейнера `3000`).
## Endpoints
### `GET /health`
Ответ:
```json
{
"ok": true
}
```
### `POST /render`
Поддерживает 2 режима:
1. `multipart/form-data`
- `template`: DOCX file
- `data`: JSON string
- опционально: `outputFileName`, `saveToS3`, `outputPath`, `download`
2. `application/json`
- `templatePath`: путь/ключ шаблона в S3
- `data`: JSON object
- опционально: `outputFileName`, `saveToS3`, `outputPath`, `download`
Поведение:
- по умолчанию возвращает JSON-метаданные
- если передать `?download=1` или `download=true` -> вернет DOCX как attachment
- если `saveToS3=true`, результат сохраняется в S3
### `POST /render-and-save`
Принимает JSON:
```json
{
"templatePath": "claim_templates/pretrial_settlement_offer_v1.docx",
"data": {
"current_date": "26.03.2026",
"claim_number": "KP-009097",
"client_fio": "Константин Поздняков",
"facts_block": "Текст обстоятельств"
},
"outputFileName": "pretrial_offer_KP-009097.docx",
"outputPath": "generated/507222/pretrial_offer_KP-009097.docx"
}
```
Ответ:
```json
{
"success": true,
"fileName": "pretrial_offer_KP-009097.docx",
"path": "generated/507222/pretrial_offer_KP-009097.docx",
"bucket": "your-bucket",
"url": "https://...",
"size": 12345
}
```
## Примеры curl
### Health
```bash
curl -s http://147.45.146.17:3015/health
```
### Проверка API key
Все рабочие endpoint-ы (`/render`, `/render-and-save`) требуют ключ:
- заголовок `x-api-key: <API_KEY>`
или
- `Authorization: Bearer <API_KEY>`
### Render (file upload)
```bash
curl -X POST "http://147.45.146.17:3015/render?download=1" \
-H "x-api-key: YOUR_API_KEY" \
-F "template=@./template.docx" \
-F 'data={"current_date":"26.03.2026","claim_number":"KP-009097"}' \
-F "outputFileName=pretrial_offer_KP-009097.docx" \
--output pretrial_offer_KP-009097.docx
```
### Render-and-save (templatePath)
```bash
curl -X POST "http://147.45.146.17:3015/render-and-save" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"templatePath": "claim_templates/pretrial_settlement_offer_v1.docx",
"data": {
"current_date": "26.03.2026",
"claim_number": "KP-009097",
"client_fio": "Константин Поздняков"
},
"outputFileName": "pretrial_offer_KP-009097.docx",
"outputPath": "generated/507222/pretrial_offer_KP-009097.docx"
}'
```
## ENV переменные
Обязательные:
- `S3_ENDPOINT`
- `S3_REGION`
- `S3_BUCKET`
- `S3_ACCESS_KEY_ID`
- `S3_SECRET_ACCESS_KEY`
Опциональные:
- `S3_FORCE_PATH_STYLE` (default: `true`)
- `S3_PUBLIC_BASE_URL` (если нужен публичный URL)
- `PORT` (default: `3000`)
- `JSON_BODY_LIMIT` (default: `2mb`)
- `MAX_TEMPLATE_SIZE_MB` (default: `10`)
- `API_KEY` (обязателен для `/render` и `/render-and-save`)
## Ошибки API (примеры)
```json
{
"success": false,
"error": "templatePath or template file is required"
}
```
```json
{
"success": false,
"error": "data must be a valid JSON object"
}
```
```json
{
"success": false,
"error": "Failed to render DOCX template",
"details": {
"id": "multi_error",
"explanation": "..."
}
}
```
## Деплой в Dokploy
Если отдельного `dokploy`-конфига нет:
1. Запушить ветку/репозиторий с `docx-renderer/app`
2. В Dokploy создать новое приложение (Dockerfile mode)
3. Указать путь к Dockerfile: `docx-renderer/app/Dockerfile`
4. Прописать env-переменные из `.env.example`
5. Открыть внешний порт на `3015` (или свой порт из compose)
6. Деплойнуть приложение и проверить `GET /health`
## Примечания по шаблонам
- Поддерживаются placeholder-ы формата `{{key}}`
- Ключи берутся из объекта `data` без хардкода
- Пустые значения не ломают рендер (подставляется пустая строка)
- Лишние ключи в `data` игнорируются
- Для многострочных значений включен `linebreaks: true`

View File

@@ -0,0 +1,22 @@
version: "3.8"
services:
docx-renderer:
build:
context: .
dockerfile: Dockerfile
container_name: docx-renderer
restart: unless-stopped
env_file:
- .env
environment:
- PORT=3000
- NODE_ENV=production
ports:
- "3015:3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s

4392
docx-renderer/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "docx-renderer-service",
"version": "1.0.0",
"description": "Self-hosted DOCX template renderer service for Clientright",
"private": true,
"main": "src/server.js",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1017.0",
"docxtemplater": "^3.68.3",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"multer": "^2.1.1",
"pizzip": "^3.2.0"
}
}

View File

@@ -0,0 +1,36 @@
require("dotenv").config();
if (!process.env.NODE_ENV) {
process.env.NODE_ENV = "production";
}
const express = require("express");
const { ApiError } = require("./utils/errors");
const { requestLogger } = require("./middleware/requestLogger");
const { errorHandler } = require("./middleware/errorHandler");
const { apiKeyAuth } = require("./middleware/apiKeyAuth");
const healthRoute = require("./routes/health");
const renderRoute = require("./routes/render");
const app = express();
app.disable("x-powered-by");
app.set("trust proxy", true);
const jsonBodyLimit = process.env.JSON_BODY_LIMIT || "2mb";
app.use(express.json({ limit: jsonBodyLimit }));
app.use(express.urlencoded({ extended: true, limit: jsonBodyLimit }));
app.use(requestLogger);
app.use("/health", healthRoute);
app.use(apiKeyAuth);
app.use(renderRoute);
app.use((_req, _res, next) => {
next(new ApiError(404, "Route not found"));
});
app.use(errorHandler);
module.exports = app;

View File

@@ -0,0 +1,35 @@
const { ApiError } = require("../utils/errors");
function extractBearerToken(authorizationHeader) {
if (!authorizationHeader || typeof authorizationHeader !== "string") {
return "";
}
const match = authorizationHeader.match(/^Bearer\s+(.+)$/i);
return match ? match[1].trim() : "";
}
function apiKeyAuth(req, _res, next) {
const configuredKey = String(process.env.API_KEY || "").trim();
if (!configuredKey) {
return next(new ApiError(500, "API key is not configured on server"));
}
const apiKeyFromHeader = String(req.headers["x-api-key"] || "").trim();
const apiKeyFromBearer = extractBearerToken(req.headers.authorization);
const providedKey = apiKeyFromHeader || apiKeyFromBearer;
if (!providedKey) {
return next(new ApiError(401, "API key is required"));
}
if (providedKey !== configuredKey) {
return next(new ApiError(401, "Invalid API key"));
}
return next();
}
module.exports = {
apiKeyAuth,
};

View File

@@ -0,0 +1,53 @@
const multer = require("multer");
const { ApiError } = require("../utils/errors");
const { logger, toSerializableError } = require("../utils/logger");
function mapError(error) {
if (error instanceof ApiError) {
return error;
}
if (error instanceof multer.MulterError) {
if (error.code === "LIMIT_FILE_SIZE") {
return new ApiError(413, "Template file is too large");
}
return new ApiError(400, error.message);
}
if (error?.type === "entity.parse.failed") {
return new ApiError(400, "Invalid JSON request body");
}
return new ApiError(500, "Internal server error");
}
function errorHandler(error, req, res, _next) {
const mapped = mapError(error);
logger.error("request_failed", {
requestId: req.requestId,
method: req.method,
path: req.originalUrl,
statusCode: mapped.statusCode,
error: toSerializableError(error),
});
const response = {
success: false,
error: mapped.message,
};
if (mapped.details !== undefined) {
response.details = mapped.details;
}
if (process.env.NODE_ENV === "development" && error?.stack) {
response.stack = error.stack;
}
res.status(mapped.statusCode).json(response);
}
module.exports = {
errorHandler,
};

View File

@@ -0,0 +1,29 @@
const { randomUUID } = require("node:crypto");
const { logger } = require("../utils/logger");
function requestLogger(req, res, next) {
const startedAt = process.hrtime.bigint();
const requestId = req.headers["x-request-id"] || randomUUID();
req.requestId = requestId;
res.setHeader("x-request-id", requestId);
res.on("finish", () => {
const elapsedNs = process.hrtime.bigint() - startedAt;
const durationMs = Number(elapsedNs) / 1_000_000;
logger.info("request_completed", {
requestId,
method: req.method,
path: req.originalUrl,
statusCode: res.statusCode,
durationMs: Number(durationMs.toFixed(2)),
ip: req.ip,
});
});
next();
}
module.exports = {
requestLogger,
};

View File

@@ -0,0 +1,9 @@
const express = require("express");
const router = express.Router();
router.get("/", (_req, res) => {
res.json({ ok: true });
});
module.exports = router;

View File

@@ -0,0 +1,84 @@
const express = require("express");
const multer = require("multer");
const { asyncHandler } = require("../utils/errors");
const { parseRenderInput, parseRenderAndSaveInput } = require("../utils/validation");
const { DOCX_MIME_TYPE, renderTemplate } = require("../services/docxRendererService");
const { getTemplateFromS3, saveResultToS3 } = require("../services/s3Service");
const router = express.Router();
const maxTemplateSizeMb = Number(process.env.MAX_TEMPLATE_SIZE_MB || 10);
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: maxTemplateSizeMb * 1024 * 1024,
},
});
function sanitizeFileNameForHeader(fileName) {
return String(fileName || "rendered.docx").replace(/["\r\n]/g, "_");
}
router.post(
"/render",
upload.single("template"),
asyncHandler(async (req, res) => {
const input = parseRenderInput(req);
let templateBuffer = input.templateBuffer;
if (!templateBuffer) {
const templateObject = await getTemplateFromS3(input.templatePath);
templateBuffer = templateObject.buffer;
}
const renderedBuffer = renderTemplate(templateBuffer, input.data);
let s3Result = null;
if (input.saveToS3) {
s3Result = await saveResultToS3(input.outputPath, renderedBuffer, DOCX_MIME_TYPE);
}
if (input.download) {
res.setHeader("Content-Type", DOCX_MIME_TYPE);
res.setHeader(
"Content-Disposition",
`attachment; filename="${sanitizeFileNameForHeader(input.outputFileName)}"`
);
return res.send(renderedBuffer);
}
return res.json({
success: true,
fileName: input.outputFileName,
size: renderedBuffer.length,
mimeType: DOCX_MIME_TYPE,
templateSource: input.sourceType,
templatePath: input.templatePath,
savedToS3: Boolean(s3Result),
bucket: s3Result?.bucket || null,
path: s3Result?.path || null,
url: s3Result?.url || null,
});
})
);
router.post(
"/render-and-save",
asyncHandler(async (req, res) => {
const input = parseRenderAndSaveInput(req.body);
const templateObject = await getTemplateFromS3(input.templatePath);
const renderedBuffer = renderTemplate(templateObject.buffer, input.data);
const saved = await saveResultToS3(input.outputPath, renderedBuffer, DOCX_MIME_TYPE);
return res.json({
success: true,
fileName: input.outputFileName,
path: saved.path,
bucket: saved.bucket,
url: saved.url,
size: saved.size,
});
})
);
module.exports = router;

View File

@@ -0,0 +1,8 @@
const app = require("./app");
const { logger } = require("./utils/logger");
const port = Number(process.env.PORT || 3000);
app.listen(port, () => {
logger.info("server_started", { port });
});

View File

@@ -0,0 +1,79 @@
const PizZip = require("pizzip");
const Docxtemplater = require("docxtemplater");
const { ApiError } = require("../utils/errors");
const DOCX_MIME_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
function normalizeTemplateData(value) {
if (value === null || value === undefined) {
return "";
}
if (Array.isArray(value)) {
return value.map((item) => normalizeTemplateData(item));
}
if (Object.prototype.toString.call(value) === "[object Object]") {
const normalized = {};
for (const [key, item] of Object.entries(value)) {
normalized[key] = normalizeTemplateData(item);
}
return normalized;
}
return value;
}
function getDocxtemplaterErrorDetails(error) {
const root = error?.properties || {};
const nestedErrors = Array.isArray(root.errors) ? root.errors : [];
return {
id: root.id || null,
explanation:
nestedErrors
.map((item) => item?.properties?.explanation)
.filter(Boolean)
.join("; ") || root.explanation || error.message,
};
}
function renderTemplate(templateBuffer, data) {
let zip;
try {
zip = new PizZip(templateBuffer);
} catch (error) {
throw new ApiError(400, "Invalid DOCX template file", error.message);
}
const doc = new Docxtemplater(zip, {
delimiters: { start: "{{", end: "}}" },
linebreaks: true,
paragraphLoop: true,
nullGetter() {
return "";
},
});
try {
doc.render(normalizeTemplateData(data));
} catch (error) {
throw new ApiError(
500,
"Failed to render DOCX template",
getDocxtemplaterErrorDetails(error)
);
}
return doc.getZip().generate({
type: "nodebuffer",
compression: "DEFLATE",
mimeType: DOCX_MIME_TYPE,
});
}
module.exports = {
DOCX_MIME_TYPE,
renderTemplate,
};

View File

@@ -0,0 +1,177 @@
const { S3Client, GetObjectCommand, PutObjectCommand } = require("@aws-sdk/client-s3");
const { ApiError } = require("../utils/errors");
const { logger } = require("../utils/logger");
const { parseBoolean, normalizeS3KeyPath } = require("../utils/validation");
let cachedClient = null;
let cachedConfig = null;
function requireEnv(name) {
const value = process.env[name];
if (!value || !String(value).trim()) {
throw new ApiError(500, `Missing required environment variable: ${name}`);
}
return String(value).trim();
}
function getS3Config() {
if (cachedConfig) {
return cachedConfig;
}
const forcePathStyle = parseBoolean(process.env.S3_FORCE_PATH_STYLE, true);
cachedConfig = {
endpoint: requireEnv("S3_ENDPOINT"),
region: process.env.S3_REGION || "ru-1",
bucket: requireEnv("S3_BUCKET"),
accessKeyId: requireEnv("S3_ACCESS_KEY_ID"),
secretAccessKey: requireEnv("S3_SECRET_ACCESS_KEY"),
forcePathStyle,
publicBaseUrl: process.env.S3_PUBLIC_BASE_URL
? String(process.env.S3_PUBLIC_BASE_URL).trim()
: "",
};
return cachedConfig;
}
function getS3Client() {
if (cachedClient) {
return cachedClient;
}
const config = getS3Config();
cachedClient = new S3Client({
endpoint: config.endpoint,
region: config.region,
forcePathStyle: config.forcePathStyle,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
return cachedClient;
}
async function streamToBuffer(body) {
if (!body) {
return Buffer.alloc(0);
}
if (Buffer.isBuffer(body)) {
return body;
}
if (typeof body.transformToByteArray === "function") {
const bytes = await body.transformToByteArray();
return Buffer.from(bytes);
}
const chunks = [];
for await (const chunk of body) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
function encodeKeyForUrl(key) {
return key
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
}
function buildS3Url(config, key) {
const normalizedKey = normalizeS3KeyPath(key);
if (!normalizedKey) {
return null;
}
if (config.publicBaseUrl) {
return `${config.publicBaseUrl.replace(/\/+$/, "")}/${encodeKeyForUrl(normalizedKey)}`;
}
try {
const endpointUrl = new URL(config.endpoint);
if (config.forcePathStyle) {
return `${endpointUrl.origin}/${encodeURIComponent(config.bucket)}/${encodeKeyForUrl(normalizedKey)}`;
}
return `${endpointUrl.protocol}//${encodeURIComponent(config.bucket)}.${endpointUrl.host}/${encodeKeyForUrl(
normalizedKey
)}`;
} catch (error) {
logger.warn("s3_url_build_failed", { message: error.message });
return null;
}
}
async function getTemplateFromS3(templatePath) {
const key = normalizeS3KeyPath(templatePath);
if (!key) {
throw new ApiError(400, "templatePath is required");
}
const client = getS3Client();
const config = getS3Config();
try {
const response = await client.send(
new GetObjectCommand({
Bucket: config.bucket,
Key: key,
})
);
return {
buffer: await streamToBuffer(response.Body),
bucket: config.bucket,
key,
url: buildS3Url(config, key),
};
} catch (error) {
if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
throw new ApiError(404, "Template not found in S3", {
templatePath: key,
});
}
throw new ApiError(500, "Failed to load template from S3", error.message);
}
}
async function saveResultToS3(outputPath, buffer, mimeType) {
const key = normalizeS3KeyPath(outputPath);
if (!key) {
throw new ApiError(400, "outputPath is required");
}
const client = getS3Client();
const config = getS3Config();
try {
await client.send(
new PutObjectCommand({
Bucket: config.bucket,
Key: key,
Body: buffer,
ContentType: mimeType,
})
);
} catch (error) {
throw new ApiError(500, "Failed to save result to S3", error.message);
}
return {
bucket: config.bucket,
path: key,
key,
size: buffer.length,
url: buildS3Url(config, key),
};
}
module.exports = {
getTemplateFromS3,
saveResultToS3,
};

View File

@@ -0,0 +1,27 @@
class ApiError extends Error {
constructor(statusCode, message, details) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.details = details;
}
}
class ValidationError extends ApiError {
constructor(message, details) {
super(400, message, details);
this.name = "ValidationError";
}
}
function asyncHandler(handler) {
return function wrappedHandler(req, res, next) {
Promise.resolve(handler(req, res, next)).catch(next);
};
}
module.exports = {
ApiError,
ValidationError,
asyncHandler,
};

View File

@@ -0,0 +1,45 @@
function toSerializableError(error) {
if (!error) {
return null;
}
return {
name: error.name,
message: error.message,
stack: error.stack,
};
}
function write(level, event, data = {}) {
const payload = {
ts: new Date().toISOString(),
level,
event,
...data,
};
const line = JSON.stringify(payload);
if (level === "error" || level === "warn") {
console.error(line);
return;
}
console.log(line);
}
const logger = {
info(event, data) {
write("info", event, data);
},
warn(event, data) {
write("warn", event, data);
},
error(event, data) {
write("error", event, data);
},
};
module.exports = {
logger,
toSerializableError,
};

View File

@@ -0,0 +1,190 @@
const { ValidationError } = require("./errors");
const DOCX_EXTENSION = ".docx";
function isPlainObject(value) {
return Object.prototype.toString.call(value) === "[object Object]";
}
function parseBoolean(value, fallback = false) {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return value === 1;
}
if (typeof value !== "string") {
return fallback;
}
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return fallback;
}
function ensureDocxFileName(fileName, fallback = "rendered.docx") {
const normalized = String(fileName || "").trim();
if (!normalized) {
return fallback;
}
return normalized.toLowerCase().endsWith(DOCX_EXTENSION)
? normalized
: `${normalized}${DOCX_EXTENSION}`;
}
function normalizeS3KeyPath(value) {
if (!value) {
return "";
}
return String(value).trim().replace(/^\/+/, "");
}
function ensureDocxKeyPath(pathValue, fallbackFileName = "rendered.docx") {
const normalized = normalizeS3KeyPath(pathValue);
if (!normalized) {
return "";
}
if (normalized.endsWith("/")) {
return `${normalized}${ensureDocxFileName(fallbackFileName)}`;
}
if (normalized.toLowerCase().endsWith(DOCX_EXTENSION)) {
return normalized;
}
return `${normalized}${DOCX_EXTENSION}`;
}
function getFileNameFromPath(pathValue) {
const normalized = normalizeS3KeyPath(pathValue);
if (!normalized || normalized.endsWith("/")) {
return "";
}
const parts = normalized.split("/");
return parts[parts.length - 1] || "";
}
function parseDataObject(rawData) {
if (rawData === undefined || rawData === null || rawData === "") {
return {};
}
let parsed = rawData;
if (typeof rawData === "string") {
try {
parsed = JSON.parse(rawData);
} catch (error) {
throw new ValidationError("data must be a valid JSON object");
}
}
if (!isPlainObject(parsed)) {
throw new ValidationError("data must be a valid JSON object");
}
return parsed;
}
function validateDocxUpload(file) {
if (!file) {
return;
}
const hasDocxExtension = String(file.originalname || "")
.toLowerCase()
.endsWith(DOCX_EXTENSION);
if (!hasDocxExtension) {
throw new ValidationError("template file must have .docx extension");
}
}
function parseRenderInput(req) {
const file = req.file;
const body = req.body || {};
validateDocxUpload(file);
const templatePath = normalizeS3KeyPath(body.templatePath);
const hasFile = Boolean(file);
const hasTemplatePath = Boolean(templatePath);
if (!hasFile && !hasTemplatePath) {
throw new ValidationError("templatePath or template file is required");
}
if (hasFile && hasTemplatePath) {
throw new ValidationError("Provide either templatePath or template file, not both");
}
const data = parseDataObject(body.data);
const outputFileName = ensureDocxFileName(
body.outputFileName || getFileNameFromPath(body.outputPath) || file?.originalname || "rendered.docx",
"rendered.docx"
);
const saveToS3 = parseBoolean(body.saveToS3, false);
const outputPath = ensureDocxKeyPath(body.outputPath, outputFileName);
const download = parseBoolean(req.query.download ?? body.download, false);
if (saveToS3 && !outputPath) {
throw new ValidationError("outputPath is required when saveToS3=true");
}
return {
data,
templatePath: hasTemplatePath ? templatePath : null,
templateBuffer: hasFile ? file.buffer : null,
outputFileName,
saveToS3,
outputPath,
download,
sourceType: hasFile ? "upload" : "s3",
};
}
function parseRenderAndSaveInput(body) {
const payload = body || {};
const templatePath = normalizeS3KeyPath(payload.templatePath);
if (!templatePath) {
throw new ValidationError("templatePath is required");
}
const data = parseDataObject(payload.data);
const outputFileName = ensureDocxFileName(
payload.outputFileName || getFileNameFromPath(payload.outputPath),
"rendered.docx"
);
const outputPath = ensureDocxKeyPath(payload.outputPath, outputFileName);
if (!outputPath) {
throw new ValidationError("outputPath is required");
}
return {
templatePath,
data,
outputFileName,
outputPath,
};
}
module.exports = {
DOCX_EXTENSION,
ensureDocxFileName,
parseBoolean,
parseRenderInput,
parseRenderAndSaveInput,
parseDataObject,
ensureDocxKeyPath,
normalizeS3KeyPath,
};

View File

@@ -0,0 +1,163 @@
<?php
/*********************************************************************************
* API-интерфейс для создания Проекта из Web-формы (версия 2)
* Принимает JSON с данными проекта
* Обязательное поле: только cf_1885 (номер полиса)
* Логика: если проект с таким номером полиса существует - возвращаем ID БЕЗ обновления
* Автор: Фёдор, 2025-12-17
********************************************************************************/
include_once 'include/Webservices/Query.php';
include_once 'modules/Users/Users.php';
require_once('include/Webservices/Utils.php');
require_once 'include/Webservices/Create.php';
require_once 'includes/Loader.php';
vimport ('includes.runtime.Globals');
vimport ('includes.runtime.BaseModel');
vimport ('includes.runtime.LanguageHandler');
/**
* Создание проекта из web-формы (версия 2 с JSON)
* Если проект с таким номером полиса уже существует - просто возвращаем его ID
*
* @param string $project_json - JSON строка с данными проекта:
* {
* "cf_1885": "E50208-306083026",
* "cf_1887": "19-11-2025",
* "cf_1889": "24-11-2025",
* "cf_2446": "..."
* }
* Примечание: sessionName передается отдельным параметром в запросе, не в JSON!
* @return string - JSON строка с project_id и is_new
*/
function vtws_createwebprojectv2($project_json, $user = false) {
$logFile = 'logs/CreateWebProjectV2.log';
$logstring = date("Y-m-d H:i:s").' REQUEST: '.json_encode($_REQUEST);
file_put_contents($logFile, $logstring.PHP_EOL, FILE_APPEND);
global $adb, $current_user;
// ========================================
// 1. ОЧИСТКА И ПАРСИНГ JSON
// ========================================
$project_json = trim($project_json);
$project_json = preg_replace('/^\xEF\xBB\xBF/', '', $project_json); // Убираем BOM
// Если строка обёрнута в кавычки — убираем
if (preg_match('/^".*"$/s', $project_json)) {
$project_json = substr($project_json, 1, -1);
$project_json = stripcslashes($project_json);
}
$logstring = date("Y-m-d H:i:s").' CLEANED JSON: '.substr($project_json, 0, 500);
file_put_contents($logFile, $logstring.PHP_EOL, FILE_APPEND);
// Парсим JSON
$data = json_decode($project_json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$error = 'Ошибка парсинга JSON: ' . json_last_error_msg();
file_put_contents($logFile, date("Y-m-d H:i:s") . ' ❌ ' . $error . PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, $error);
}
// Извлекаем данные
$policy_number = trim($data['cf_1885'] ?? '');
$period_start = trim($data['cf_1887'] ?? '');
$period_end = trim($data['cf_1889'] ?? '');
$cf_2446 = trim($data['cf_2446'] ?? '');
// ========================================
// 2. ВАЛИДАЦИЯ
// ========================================
if(empty($policy_number)){
$logstring = date("Y-m-d H:i:s").' Не указано обязательное поле: cf_1885 (номер полиса)';
file_put_contents($logFile, $logstring.PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не указан номер полиса (cf_1885)");
}
// Валидация: убираем пробелы из номера полиса
$policy_number = trim($policy_number);
$logstring = date('Y-m-d H:i:s').' Ищем проект по policy_number='.$policy_number.PHP_EOL;
file_put_contents($logFile, $logstring, FILE_APPEND);
$isNew = false; // Флаг: создан ли проект сейчас
// ========================================
// 3. ПОИСК СУЩЕСТВУЮЩЕГО ПРОЕКТА
// ========================================
// Ищем только по номеру полиса (БЕЗ привязки к контакту)
$query = "SELECT p.projectid
FROM vtiger_project p
INNER JOIN vtiger_projectcf pcf ON p.projectid = pcf.projectid
INNER JOIN vtiger_crmentity e ON e.crmid = p.projectid
WHERE e.deleted = 0
AND pcf.cf_1885 = ?
LIMIT 1";
$result = $adb->pquery($query, array($policy_number));
if ($adb->num_rows($result) > 0) {
// Проект существует - ПРОСТО ВОЗВРАЩАЕМ ID (НЕ обновляем!)
$output = $adb->query_result($result, 0, 'projectid');
$isNew = false;
$logstring = date('Y-m-d H:i:s').' ✅ Проект найден с id '.$output.' (БЕЗ обновления)'.PHP_EOL;
file_put_contents($logFile, $logstring, FILE_APPEND);
} else {
// Проект НЕ существует - создаём новый
// Формируем название проекта
$projectname = 'ERV ' . $policy_number . ' цифровой адвокат';
$params = array (
'projectname' => $projectname,
'projectstatus' => 'модерация',
'projecttype' => 'ЕРВ урегулирование',
// ❌ УБРАНО: linktoaccountscontacts - нет привязки к контакту
'cf_1994' => '11x67458', // Заявитель (контрагент record=67458)
'cf_1885' => $policy_number, // Номер полиса
'assigned_user_id' => '19x8' // ✅ Ответственный = пользователь с ID 8
);
// Дополнительные необязательные поля
if (!empty($period_start)) {
$params['cf_1887'] = $period_start; // Период страхования начало
}
if (!empty($period_end)) {
$params['cf_1889'] = $period_end; // Период страхования конец
}
if (!empty($cf_2446)) {
$params['cf_2446'] = $cf_2446;
}
$logstring = date('Y-m-d H:i:s').' Массив для создания Web Проекта: '.json_encode($params).PHP_EOL;
file_put_contents($logFile, $logstring, FILE_APPEND);
try {
$project = vtws_create('Project', $params, $current_user);
$output = substr($project['id'], 3);
$isNew = true; // Проект только что создан!
$logstring = date('Y-m-d H:i:s').' ✅ Создан новый Web Проект с id '.$output.PHP_EOL;
file_put_contents($logFile, $logstring, FILE_APPEND);
} catch (WebServiceException $ex) {
$logstring = date('Y-m-d H:i:s').' ❌ Ошибка создания: '.$ex->getMessage().PHP_EOL;
file_put_contents($logFile, $logstring, FILE_APPEND);
throw $ex;
}
}
// Возвращаем JSON с флагом is_new
$result = array(
'project_id' => $output,
'is_new' => $isNew
);
$logstring = date('Y-m-d H:i:s').' Return: '.json_encode($result).PHP_EOL;
file_put_contents($logFile, $logstring, FILE_APPEND);
return json_encode($result);
}
?>

View File

@@ -918,11 +918,17 @@ class AIDrawer {
// Проверяем несколько раз с интервалом (на случай если ответ еще обрабатывается)
let attempts = 0;
const maxAttempts = 30; // 30 попыток = 1 минута (каждые 2 секунды)
const maxAttempts = 150; // 150 попыток = 5 минут (каждые 2 секунды)
const longWaitNoticeAttempt = 30; // 30 * 2с = 60 секунд
let longWaitNoticeShown = false;
const checkInterval = setInterval(async () => {
attempts++;
console.log(`AI Drawer: Redis check attempt ${attempts}/${maxAttempts}`);
if (!longWaitNoticeShown && attempts === longWaitNoticeAttempt) {
longWaitNoticeShown = true;
this.addStreamingMessage('Обработка идет дольше обычного. Продолжаю ждать ответ...', false, 25);
}
try {
const response = await fetch(`/aiassist/check_redis_response.php?task_id=${encodeURIComponent(taskId)}`);
@@ -942,7 +948,7 @@ class AIDrawer {
clearInterval(checkInterval);
this.hideLoading();
this.hideTypingIndicator();
this.addStreamingMessage('Ответ не получен. Попробуйте отправить запрос еще раз.', false, 25);
this.addStreamingMessage('Ответ не получен в течение 5 минут. Попробуйте отправить запрос еще раз.', false, 25);
}
} catch (error) {
console.error('AI Drawer: Redis direct check error:', error);