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:
4
docx-renderer/app/.dockerignore
Normal file
4
docx-renderer/app/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
15
docx-renderer/app/.env.example
Normal file
15
docx-renderer/app/.env.example
Normal 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
3
docx-renderer/app/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
npm-debug.log*
|
||||
236
docx-renderer/app/DOCX_RENDERER_MANUAL_RU.md
Normal file
236
docx-renderer/app/DOCX_RENDERER_MANUAL_RU.md
Normal 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
|
||||
15
docx-renderer/app/Dockerfile
Normal file
15
docx-renderer/app/Dockerfile
Normal 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
232
docx-renderer/app/README.md
Normal 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`
|
||||
22
docx-renderer/app/docker-compose.yml
Normal file
22
docx-renderer/app/docker-compose.yml
Normal 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
4392
docx-renderer/app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
docx-renderer/app/package.json
Normal file
22
docx-renderer/app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
36
docx-renderer/app/src/app.js
Normal file
36
docx-renderer/app/src/app.js
Normal 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;
|
||||
35
docx-renderer/app/src/middleware/apiKeyAuth.js
Normal file
35
docx-renderer/app/src/middleware/apiKeyAuth.js
Normal 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,
|
||||
};
|
||||
53
docx-renderer/app/src/middleware/errorHandler.js
Normal file
53
docx-renderer/app/src/middleware/errorHandler.js
Normal 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,
|
||||
};
|
||||
29
docx-renderer/app/src/middleware/requestLogger.js
Normal file
29
docx-renderer/app/src/middleware/requestLogger.js
Normal 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,
|
||||
};
|
||||
9
docx-renderer/app/src/routes/health.js
Normal file
9
docx-renderer/app/src/routes/health.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const express = require("express");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
84
docx-renderer/app/src/routes/render.js
Normal file
84
docx-renderer/app/src/routes/render.js
Normal 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;
|
||||
8
docx-renderer/app/src/server.js
Normal file
8
docx-renderer/app/src/server.js
Normal 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 });
|
||||
});
|
||||
79
docx-renderer/app/src/services/docxRendererService.js
Normal file
79
docx-renderer/app/src/services/docxRendererService.js
Normal 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,
|
||||
};
|
||||
177
docx-renderer/app/src/services/s3Service.js
Normal file
177
docx-renderer/app/src/services/s3Service.js
Normal 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,
|
||||
};
|
||||
27
docx-renderer/app/src/utils/errors.js
Normal file
27
docx-renderer/app/src/utils/errors.js
Normal 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,
|
||||
};
|
||||
45
docx-renderer/app/src/utils/logger.js
Normal file
45
docx-renderer/app/src/utils/logger.js
Normal 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,
|
||||
};
|
||||
190
docx-renderer/app/src/utils/validation.js
Normal file
190
docx-renderer/app/src/utils/validation.js
Normal 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,
|
||||
};
|
||||
163
include/Webservices/CreateWebProjectV2.php
Normal file
163
include/Webservices/CreateWebProjectV2.php
Normal 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);
|
||||
}
|
||||
?>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user