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

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

1265 lines
47 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Детальный промт для создания мультитенантной системы управления рабочими процессами
## Инструкции по общению
**ВАЖНО**: После создания приложения **всё дальнейшее общение должно происходить на русском языке**. Интерфейс приложения также должен быть полностью на русском языке.
## Описание проекта
Создай корпоративную мультитенантную систему управления рабочими процессами с расширенным функционалом. Система должна поддерживать множество организаций с полной изоляцией данных.
---
## ЭТАП 1: ОСНОВНАЯ АРХИТЕКТУРА И АВТОРИЗАЦИЯ
### 🎯 Цель этапа
Создать базовую мультитенантную архитектуру с системой авторизации и управлением пользователями.
### 📁 Структура проекта
```
project-root/
├── backend/
│ ├── src/
│ │ ├── controllers/
│ │ │ ├── auth.controller.js
│ │ │ ├── organization.controller.js
│ │ │ └── user.controller.js
│ │ ├── middleware/
│ │ │ ├── auth.middleware.js
│ │ │ ├── tenant.middleware.js
│ │ │ └── validation.middleware.js
│ │ ├── models/
│ │ │ ├── Organization.js
│ │ │ ├── User.js
│ │ │ └── index.js
│ │ ├── routes/
│ │ │ ├── auth.routes.js
│ │ │ ├── organization.routes.js
│ │ │ └── user.routes.js
│ │ ├── services/
│ │ │ ├── auth.service.js
│ │ │ ├── email.service.js
│ │ │ └── tenant.service.js
│ │ ├── utils/
│ │ │ ├── database.js
│ │ │ ├── logger.js
│ │ │ └── validation.js
│ │ ├── config/
│ │ │ ├── database.config.js
│ │ │ └── jwt.config.js
│ │ └── app.js
│ ├── package.json
│ ├── Dockerfile
│ └── .env.example
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ │ ├── auth/
│ │ │ │ ├── LoginForm.jsx
│ │ │ │ └── RegisterForm.jsx
│ │ │ ├── common/
│ │ │ │ ├── Header.jsx
│ │ │ │ ├── Navigation.jsx
│ │ │ │ └── Layout.jsx
│ │ │ └── users/
│ │ │ └── UserList.jsx
│ │ ├── pages/
│ │ │ ├── Auth/
│ │ │ │ ├── Login.jsx
│ │ │ │ └── Register.jsx
│ │ │ ├── Dashboard.jsx
│ │ │ └── Users.jsx
│ │ ├── services/
│ │ │ ├── api.service.js
│ │ │ └── auth.service.js
│ │ ├── utils/
│ │ │ ├── constants.js
│ │ │ └── helpers.js
│ │ ├── App.jsx
│ │ └── index.js
│ ├── package.json
│ └── Dockerfile
├── docker-compose.yml
└── README.md
```
### 🗄️ Схема базы данных PostgreSQL
**Создай следующие таблицы:**
```sql
-- Таблица организаций
CREATE TABLE organizations (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(50) NOT NULL UNIQUE, -- org1, org2 и т.д.
display_name VARCHAR(200) NOT NULL,
logo_url VARCHAR(500),
theme_color VARCHAR(7) DEFAULT '#1976d2',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT true
);
-- Таблица пользователей
CREATE TABLE users (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
position VARCHAR(200),
role VARCHAR(20) NOT NULL DEFAULT 'user', -- 'admin', 'user'
is_active BOOLEAN DEFAULT true,
email_verified BOOLEAN DEFAULT false,
verification_token VARCHAR(255),
reset_password_token VARCHAR(255),
reset_password_expires TIMESTAMP,
last_login TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(organization_id, email)
);
-- Таблица сессий пользователей
CREATE TABLE user_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
refresh_token VARCHAR(500) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address INET,
user_agent TEXT
);
-- Индексы для производительности
CREATE INDEX idx_organizations_slug ON organizations(slug);
CREATE INDEX idx_users_organization_email ON users(organization_id, email);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_user_sessions_token ON user_sessions(refresh_token);
CREATE INDEX idx_user_sessions_user ON user_sessions(user_id);
```
### 🔐 JWT Авторизация - Детальная реализация
**Backend - JWT конфигурация (config/jwt.config.js):**
```javascript
module.exports = {
accessTokenSecret: process.env.JWT_ACCESS_SECRET || 'access-secret-key',
refreshTokenSecret: process.env.JWT_REFRESH_SECRET || 'refresh-secret-key',
accessTokenExpiry: '15m', // 15 минут
refreshTokenExpiry: '7d', // 7 дней
issuer: 'workflow-system',
audience: 'workflow-users'
};
```
**Middleware для авторизации (middleware/auth.middleware.js):**
```javascript
const jwt = require('jsonwebtoken');
const { accessTokenSecret } = require('../config/jwt.config');
const User = require('../models/User');
const authenticateToken = async (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'Токен доступа отсутствует' });
}
try {
const decoded = jwt.verify(token, accessTokenSecret);
const user = await User.findById(decoded.userId);
if (!user || !user.is_active) {
return res.status(401).json({ error: 'Пользователь не найден или заблокирован' });
}
req.user = user;
req.organizationId = user.organization_id;
next();
} catch (error) {
return res.status(403).json({ error: 'Недействительный токен' });
}
};
const requireAdmin = (req, res, next) => {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Требуются права администратора' });
}
next();
};
module.exports = { authenticateToken, requireAdmin };
```
**Middleware для мультитенантности (middleware/tenant.middleware.js):**
```javascript
const tenantIsolation = (req, res, next) => {
// Добавляем organization_id во все запросы к базе данных
if (req.user && req.user.organization_id) {
req.tenantId = req.user.organization_id;
// Модифицируем все DB запросы для изоляции данных
const originalQuery = req.query || {};
req.query = {
...originalQuery,
organization_id: req.tenantId
};
}
next();
};
module.exports = { tenantIsolation };
```
### 🚀 API Endpoints - Детальное описание
**Регистрация организации (POST /api/auth/register-organization):**
```javascript
// Входные данные:
{
"organizationName": "Моя Компания",
"organizationSlug": "my-company", // только латиница и дефисы
"adminEmail": "admin@company.com",
"adminFirstName": "Иван",
"adminLastName": "Петров",
"adminPassword": "SecurePassword123!"
}
// Ответ при успехе:
{
"success": true,
"message": "Организация создана. Проверьте email для подтверждения.",
"organizationId": 1,
"verificationSent": true
}
// Ответ при ошибке:
{
"success": false,
"error": "Организация с таким именем уже существует",
"details": {
"field": "organizationSlug",
"message": "Слаг должен быть уникальным"
}
}
```
**Вход в систему (POST /api/auth/login):**
```javascript
// Входные данные:
{
"email": "user@company.com",
"password": "password123",
"organizationSlug": "my-company" // опционально, если не указан - ищем по email
}
// Ответ при успехе:
{
"success": true,
"user": {
"id": 1,
"email": "user@company.com",
"firstName": "Иван",
"lastName": "Петров",
"role": "admin",
"organization": {
"id": 1,
"name": "Моя Компания",
"slug": "my-company"
}
},
"tokens": {
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 900 // секунды
}
}
```
**Обновление токена (POST /api/auth/refresh):**
```javascript
// Входные данные:
{
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}
// Ответ:
{
"success": true,
"tokens": {
"accessToken": "new-access-token",
"refreshToken": "new-refresh-token",
"expiresIn": 900
}
}
```
### 🔒 Безопасность - Детальная реализация
**Хэширование паролей (services/auth.service.js):**
```javascript
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // Высокий уровень безопасности
const hashPassword = async (password) => {
// Проверка сложности пароля
if (password.length < 8) {
throw new Error('Пароль должен содержать минимум 8 символов');
}
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumbers = /\d/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
if (!hasUpperCase || !hasLowerCase || !hasNumbers || !hasSpecialChar) {
throw new Error('Пароль должен содержать заглавные и строчные буквы, цифры и специальные символы');
}
return await bcrypt.hash(password, SALT_ROUNDS);
};
const verifyPassword = async (password, hash) => {
return await bcrypt.compare(password, hash);
};
module.exports = { hashPassword, verifyPassword };
```
**Rate Limiting (middleware/rateLimit.middleware.js):**
```javascript
const rateLimit = require('express-rate-limit');
// Ограничение для авторизации
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 минут
max: 5, // максимум 5 попыток входа за 15 минут
message: {
error: 'Слишком много попыток входа. Попробуйте через 15 минут.'
},
standardHeaders: true,
legacyHeaders: false
});
// Ограничение для регистрации
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 час
max: 3, // максимум 3 регистрации в час с одного IP
message: {
error: 'Превышен лимит регистраций. Попробуйте через час.'
}
});
// Общий лимит для API
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 минут
max: 1000, // 1000 запросов за 15 минут
message: {
error: 'Превышен лимит запросов к API'
}
});
module.exports = { authLimiter, registerLimiter, apiLimiter };
```
### 📧 Email сервис
**Email отправка (services/email.service.js):**
```javascript
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransporter({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
const sendVerificationEmail = async (email, firstName, verificationToken, organizationName) => {
const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`;
const mailOptions = {
from: process.env.FROM_EMAIL || 'noreply@workflow.com',
to: email,
subject: `Подтверждение email в ${organizationName}`,
html: `
<h2>Добро пожаловать в ${organizationName}!</h2>
<p>Здравствуйте, ${firstName}!</p>
<p>Для завершения регистрации перейдите по ссылке:</p>
<a href="${verificationUrl}" style="background: #1976d2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
Подтвердить email
</a>
<p>Ссылка действительна в течение 24 часов.</p>
`
};
await transporter.sendMail(mailOptions);
};
const sendInvitationEmail = async (email, firstName, inviterName, organizationName, tempPassword) => {
const loginUrl = `${process.env.FRONTEND_URL}/login`;
const mailOptions = {
from: process.env.FROM_EMAIL,
to: email,
subject: `Приглашение в ${organizationName}`,
html: `
<h2>Вы приглашены в ${organizationName}</h2>
<p>Здравствуйте, ${firstName}!</p>
<p>${inviterName} пригласил вас присоединиться к команде.</p>
<p><strong>Временный пароль:</strong> ${tempPassword}</p>
<p>Обязательно смените пароль после первого входа.</p>
<a href="${loginUrl}" style="background: #1976d2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
Войти в систему
</a>
`
};
await transporter.sendMail(mailOptions);
};
module.exports = { sendVerificationEmail, sendInvitationEmail };
```
### 🐳 Docker конфигурация
**docker-compose.yml:**
```yaml
version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_DB: workflow_system
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql
backend:
build: ./backend
ports:
- "3001:3001"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://postgres:password@postgres:5432/workflow_system
- JWT_ACCESS_SECRET=your-super-secret-access-key
- JWT_REFRESH_SECRET=your-super-secret-refresh-key
- SMTP_HOST=smtp.gmail.com
- SMTP_USER=your-email@gmail.com
- SMTP_PASS=your-app-password
- FRONTEND_URL=http://localhost:3000
depends_on:
- postgres
volumes:
- ./backend:/app
- /app/node_modules
frontend:
build: ./frontend
ports:
- "3000:3000"
environment:
- REACT_APP_API_URL=http://localhost:3001/api
volumes:
- ./frontend:/app
- /app/node_modules
depends_on:
- backend
volumes:
postgres_data:
```
**Backend Dockerfile:**
```dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3001
CMD ["npm", "start"]
```
### 🎨 Frontend компоненты - Детальная реализация
**Форма регистрации организации (components/auth/RegisterForm.jsx):**
```jsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { authService } from '../../services/auth.service';
const RegisterForm = () => {
const [formData, setFormData] = useState({
organizationName: '',
organizationSlug: '',
adminEmail: '',
adminFirstName: '',
adminLastName: '',
adminPassword: '',
confirmPassword: ''
});
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const validateForm = () => {
const newErrors = {};
if (!formData.organizationName.trim()) {
newErrors.organizationName = 'Название организации обязательно';
}
if (!formData.organizationSlug.trim()) {
newErrors.organizationSlug = 'Код организации обязателен';
} else if (!/^[a-z0-9-]+$/.test(formData.organizationSlug)) {
newErrors.organizationSlug = 'Код может содержать только латинские буквы, цифры и дефисы';
}
if (!formData.adminEmail.trim()) {
newErrors.adminEmail = 'Email обязателен';
} else if (!/\S+@\S+\.\S+/.test(formData.adminEmail)) {
newErrors.adminEmail = 'Неверный формат email';
}
if (!formData.adminFirstName.trim()) {
newErrors.adminFirstName = 'Имя обязательно';
}
if (!formData.adminLastName.trim()) {
newErrors.adminLastName = 'Фамилия обязательна';
}
if (!formData.adminPassword) {
newErrors.adminPassword = 'Пароль обязателен';
} else if (formData.adminPassword.length < 8) {
newErrors.adminPassword = 'Пароль должен содержать минимум 8 символов';
}
if (formData.adminPassword !== formData.confirmPassword) {
newErrors.confirmPassword = 'Пароли не совпадают';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) return;
setLoading(true);
try {
await authService.registerOrganization(formData);
alert('Организация создана! Проверьте email для подтверждения.');
navigate('/login');
} catch (error) {
setErrors({ submit: error.message });
} finally {
setLoading(false);
}
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Автоматическое создание slug из названия организации
if (name === 'organizationName') {
const slug = value
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.substring(0, 50);
setFormData(prev => ({ ...prev, organizationSlug: slug }));
}
// Очистка ошибок при изменении поля
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }));
}
};
return (
<div className="register-form">
<h2>Создание организации</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Название организации *</label>
<input
type="text"
name="organizationName"
value={formData.organizationName}
onChange={handleChange}
placeholder="ООО 'Моя Компания'"
required
/>
{errors.organizationName && <span className="error">{errors.organizationName}</span>}
</div>
<div className="form-group">
<label>Код организации * (только латиница и дефисы)</label>
<input
type="text"
name="organizationSlug"
value={formData.organizationSlug}
onChange={handleChange}
placeholder="my-company"
required
/>
{errors.organizationSlug && <span className="error">{errors.organizationSlug}</span>}
</div>
<div className="form-group">
<label>Email администратора *</label>
<input
type="email"
name="adminEmail"
value={formData.adminEmail}
onChange={handleChange}
placeholder="admin@company.com"
required
/>
{errors.adminEmail && <span className="error">{errors.adminEmail}</span>}
</div>
<div className="form-row">
<div className="form-group">
<label>Имя *</label>
<input
type="text"
name="adminFirstName"
value={formData.adminFirstName}
onChange={handleChange}
placeholder="Иван"
required
/>
{errors.adminFirstName && <span className="error">{errors.adminFirstName}</span>}
</div>
<div className="form-group">
<label>Фамилия *</label>
<input
type="text"
name="adminLastName"
value={formData.adminLastName}
onChange={handleChange}
placeholder="Петров"
required
/>
{errors.adminLastName && <span className="error">{errors.adminLastName}</span>}
</div>
</div>
<div className="form-group">
<label>Пароль * (минимум 8 символов)</label>
<input
type="password"
name="adminPassword"
value={formData.adminPassword}
onChange={handleChange}
placeholder="••••••••"
required
/>
{errors.adminPassword && <span className="error">{errors.adminPassword}</span>}
</div>
<div className="form-group">
<label>Подтверждение пароля *</label>
<input
type="password"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
placeholder="••••••••"
required
/>
{errors.confirmPassword && <span className="error">{errors.confirmPassword}</span>}
</div>
{errors.submit && <div className="error-message">{errors.submit}</div>}
<button type="submit" disabled={loading} className="btn-primary">
{loading ? 'Создание...' : 'Создать организацию'}
</button>
</form>
</div>
);
};
export default RegisterForm;
```
### ✅ Критерии завершения Этапа 1
**Функциональные требования:**
1. ✅ PostgreSQL база данных настроена и работает
2. ✅ Таблицы organizations, users, user_sessions созданы
3. ✅ JWT авторизация с access и refresh токенами работает
4. ✅ Регистрация организации с email подтверждением
5. ✅ Вход в систему с проверкой организации
6. ✅ Middleware для мультитенантности изолирует данные
7. ✅ Rate limiting защищает от брутфорса
8. ✅ Админ может добавлять пользователей в организацию
9. ✅ Frontend формы регистрации и входа работают
10. ✅ Docker контейнеры запускаются и работают
**Технические требования:**
1. ✅ API endpoints отвечают согласно спецификации
2. ✅ Валидация входных данных на backend и frontend
3. ✅ Ошибки обрабатываются и логируются
4. ✅ CORS настроен правильно
5. ✅ Environment переменные используются
6. ✅ Пароли хэшируются с bcrypt
7. ✅ Email отправка работает
8. ✅ Мультитенантность изолирует данные между организациями
**Тестирование:**
1. Зарегистрируй 2 разные организации
2. Создай пользователей в каждой организации
3. Убедись, что пользователи видят только данные своей организации
4. Проверь, что неавторизованные запросы блокируются
5. Проверь работу refresh токенов
---
## ЭТАП 2: СИСТЕМА ФОРМ И ЗАДАЧ
### 🎯 Цель этапа
Создать систему динамических форм с различными типами полей и систему задач на основе этих форм.
### 🗄️ Расширение схемы базы данных
```sql
-- Таблица форм
CREATE TABLE forms (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
created_by INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT true
);
-- Таблица полей форм
CREATE TABLE form_fields (
id SERIAL PRIMARY KEY,
form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
code VARCHAR(100) NOT NULL, -- уникальный код поля внутри формы
type VARCHAR(50) NOT NULL, -- text, number, date, select, checkbox, toggle, button, user, file, textarea, rich-text, datetime, history-number
is_required BOOLEAN DEFAULT false,
placeholder TEXT,
default_value TEXT,
validation_rules JSONB, -- правила валидации в формате JSON
options JSONB, -- опции для select полей
position INTEGER NOT NULL DEFAULT 0, -- порядок отображения
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(form_id, code)
);
-- Таблица статусов форм
CREATE TABLE form_statuses (
id SERIAL PRIMARY KEY,
form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
color VARCHAR(7) NOT NULL DEFAULT '#e5e7eb',
position INTEGER NOT NULL DEFAULT 0,
is_initial BOOLEAN DEFAULT false, -- начальный статус
is_final BOOLEAN DEFAULT false, -- финальный статус
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Таблица правил переходов между статусами
CREATE TABLE status_transitions (
id SERIAL PRIMARY KEY,
form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
from_status_id INTEGER NOT NULL REFERENCES form_statuses(id),
to_status_id INTEGER NOT NULL REFERENCES form_statuses(id),
action_type VARCHAR(20) NOT NULL, -- 'approve', 'reject', 'custom'
action_name VARCHAR(100), -- название кнопки действия
conditions JSONB, -- условия для перехода
required_role VARCHAR(20), -- роль, необходимая для действия
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Таблица задач
CREATE TABLE tasks (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
form_id INTEGER NOT NULL REFERENCES forms(id),
title VARCHAR(500) NOT NULL,
description TEXT,
current_status_id INTEGER NOT NULL REFERENCES form_statuses(id),
assigned_to INTEGER REFERENCES users(id), -- ответственный
created_by INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
due_date TIMESTAMP -- дедлайн
);
-- Таблица значений полей задач
CREATE TABLE task_field_values (
id SERIAL PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
field_id INTEGER NOT NULL REFERENCES form_fields(id),
value JSONB, -- значение поля в JSON формате
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(task_id, field_id)
);
-- Таблица истории изменений для history-number полей
CREATE TABLE field_history (
id SERIAL PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
field_id INTEGER NOT NULL REFERENCES form_fields(id),
old_value JSONB,
new_value JSONB,
changed_by INTEGER NOT NULL REFERENCES users(id),
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Индексы для производительности
CREATE INDEX idx_forms_organization ON forms(organization_id);
CREATE INDEX idx_form_fields_form ON form_fields(form_id);
CREATE INDEX idx_form_statuses_form ON form_statuses(form_id);
CREATE INDEX idx_tasks_organization ON tasks(organization_id);
CREATE INDEX idx_tasks_form ON tasks(form_id);
CREATE INDEX idx_tasks_status ON tasks(current_status_id);
CREATE INDEX idx_tasks_assigned ON tasks(assigned_to);
CREATE INDEX idx_task_field_values_task ON task_field_values(task_id);
CREATE INDEX idx_field_history_task_field ON field_history(task_id, field_id);
```
### 📝 Типы полей - Детальная реализация
**Валидация для каждого типа поля (utils/fieldValidation.js):**
```javascript
const fieldValidators = {
text: (value, rules = {}) => {
if (!value && rules.required) return 'Поле обязательно для заполнения';
if (value && rules.minLength && value.length < rules.minLength) {
return `Минимальная длина: ${rules.minLength} символов`;
}
if (value && rules.maxLength && value.length > rules.maxLength) {
return `Максимальная длина: ${rules.maxLength} символов`;
}
if (value && rules.pattern && !new RegExp(rules.pattern).test(value)) {
return rules.patternMessage || 'Неверный формат';
}
return null;
},
number: (value, rules = {}) => {
if (!value && rules.required) return 'Поле обязательно для заполнения';
if (value !== null && value !== undefined) {
const num = Number(value);
if (isNaN(num)) return 'Должно быть числом';
if (rules.min !== undefined && num < rules.min) {
return `Минимальное значение: ${rules.min}`;
}
if (rules.max !== undefined && num > rules.max) {
return `Максимальное значение: ${rules.max}`;
}
}
return null;
},
date: (value, rules = {}) => {
if (!value && rules.required) return 'Поле обязательно для заполнения';
if (value) {
const date = new Date(value);
if (isNaN(date.getTime())) return 'Неверный формат даты';
if (rules.minDate && date < new Date(rules.minDate)) {
return `Дата не может быть раньше ${rules.minDate}`;
}
if (rules.maxDate && date > new Date(rules.maxDate)) {
return `Дата не может быть позже ${rules.maxDate}`;
}
}
return null;
},
datetime: (value, rules = {}) => {
return fieldValidators.date(value, rules); // Та же валидация что и для date
},
select: (value, rules = {}, options = []) => {
if (!value && rules.required) return 'Поле обязательно для заполнения';
if (value && options.length > 0) {
const validOption = options.find(opt => opt.id === value);
if (!validOption) return 'Неверное значение';
}
return null;
},
checkbox: (value, rules = {}) => {
if (rules.required && !value) return 'Поле должно быть отмечено';
return null;
},
toggle: (value, rules = {}) => {
return fieldValidators.checkbox(value, rules);
},
user: (value, rules = {}) => {
if (!value && rules.required) return 'Необходимо выбрать пользователя';
if (value && !Number.isInteger(Number(value))) {
return 'Неверный ID пользователя';
}
return null;
},
file: (value, rules = {}) => {
if (!value && rules.required) return 'Необходимо загрузить файл';
if (value && rules.maxFileSize && value.size > rules.maxFileSize) {
return `Максимальный размер файла: ${rules.maxFileSize / 1024 / 1024} МБ`;
}
if (value && rules.allowedTypes && !rules.allowedTypes.includes(value.type)) {
return `Разрешенные типы файлов: ${rules.allowedTypes.join(', ')}`;
}
return null;
},
textarea: (value, rules = {}) => {
return fieldValidators.text(value, rules);
},
'rich-text': (value, rules = {}) => {
// Проверяем HTML контент
if (!value && rules.required) return 'Поле обязательно для заполнения';
if (value && rules.maxLength) {
// Удаляем HTML теги для подсчета длины текста
const textContent = value.replace(/<[^>]*>/g, '');
if (textContent.length > rules.maxLength) {
return `Максимальная длина текста: ${rules.maxLength} символов`;
}
}
return null;
},
'history-number': (value, rules = {}) => {
return fieldValidators.number(value, rules);
}
};
const validateFieldValue = (fieldType, value, validationRules, options) => {
const validator = fieldValidators[fieldType];
if (!validator) {
throw new Error(`Неизвестный тип поля: ${fieldType}`);
}
return validator(value, validationRules, options);
};
module.exports = { validateFieldValue, fieldValidators };
```
### 🔧 API для форм - Детальная реализация
**CRUD операции для форм (controllers/forms.controller.js):**
```javascript
const Form = require('../models/Form');
const FormField = require('../models/FormField');
const FormStatus = require('../models/FormStatus');
const { validateFieldValue } = require('../utils/fieldValidation');
// Получить все формы организации
const getForms = async (req, res) => {
try {
const { page = 1, limit = 20, search } = req.query;
const offset = (page - 1) * limit;
let whereClause = { organization_id: req.tenantId, is_active: true };
if (search) {
whereClause.name = { [Op.iLike]: `%${search}%` };
}
const { rows: forms, count } = await Form.findAndCountAll({
where: whereClause,
include: [
{
model: FormField,
as: 'fields',
order: [['position', 'ASC']]
},
{
model: FormStatus,
as: 'statuses',
order: [['position', 'ASC']]
}
],
limit: parseInt(limit),
offset: parseInt(offset),
order: [['created_at', 'DESC']]
});
res.json({
success: true,
data: forms,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: count,
pages: Math.ceil(count / limit)
}
});
} catch (error) {
console.error('Ошибка получения форм:', error);
res.status(500).json({ success: false, error: 'Ошибка сервера' });
}
};
// Создать новую форму
const createForm = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { name, description, fields = [], statuses = [] } = req.body;
// Валидация входных данных
if (!name || name.trim().length === 0) {
return res.status(400).json({
success: false,
error: 'Название формы обязательно'
});
}
// Создаем форму
const form = await Form.create({
organization_id: req.tenantId,
name: name.trim(),
description: description?.trim(),
created_by: req.user.id
}, { transaction });
// Создаем поля формы
const createdFields = [];
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
// Валидация поля
if (!field.name || !field.type) {
throw new Error(`Поле ${i + 1}: название и тип обязательны`);
}
const createdField = await FormField.create({
form_id: form.id,
name: field.name.trim(),
code: field.code || `field_${i + 1}`,
type: field.type,
is_required: field.is_required || false,
placeholder: field.placeholder,
default_value: field.default_value,
validation_rules: field.validation_rules || {},
options: field.options || [],
position: i
}, { transaction });
createdFields.push(createdField);
}
// Создаем статусы (если не указаны, создаем по умолчанию)
const defaultStatuses = statuses.length > 0 ? statuses : [
{ name: 'Создана', color: '#e5e7eb', is_initial: true },
{ name: 'В работе', color: '#bfdbfe' },
{ name: 'Завершена', color: '#d1fae5', is_final: true }
];
const createdStatuses = [];
for (let i = 0; i < defaultStatuses.length; i++) {
const status = defaultStatuses[i];
const createdStatus = await FormStatus.create({
form_id: form.id,
name: status.name,
color: status.color || '#e5e7eb',
position: i,
is_initial: i === 0 || status.is_initial,
is_final: i === defaultStatuses.length - 1 || status.is_final
}, { transaction });
createdStatuses.push(createdStatus);
}
await transaction.commit();
// Возвращаем созданную форму с полями и статусами
const fullForm = await Form.findByPk(form.id, {
include: [
{ model: FormField, as: 'fields', order: [['position', 'ASC']] },
{ model: FormStatus, as: 'statuses', order: [['position', 'ASC']] }
]
});
res.status(201).json({
success: true,
data: fullForm,
message: 'Форма успешно создана'
});
} catch (error) {
await transaction.rollback();
console.error('Ошибка создания формы:', error);
res.status(400).json({
success: false,
error: error.message || 'Ошибка создания формы'
});
}
};
// Обновить форму
const updateForm = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { id } = req.params;
const { name, description, fields, statuses } = req.body;
// Находим форму
const form = await Form.findOne({
where: { id, organization_id: req.tenantId }
});
if (!form) {
return res.status(404).json({
success: false,
error: 'Форма не найдена'
});
}
// Обновляем основную информацию формы
if (name) form.name = name.trim();
if (description !== undefined) form.description = description?.trim();
form.updated_at = new Date();
await form.save({ transaction });
// Обновляем поля, если переданы
if (fields && Array.isArray(fields)) {
// Удаляем старые поля
await FormField.destroy({
where: { form_id: form.id },
transaction
});
// Создаем новые поля
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
await FormField.create({
form_id: form.id,
name: field.name.trim(),
code: field.code || `field_${i + 1}`,
type: field.type,
is_required: field.is_required || false,
placeholder: field.placeholder,
default_value: field.default_value,
validation_rules: field.validation_rules || {},
options: field.options || [],
position: i
}, { transaction });
}
}
// Обновляем статусы, если переданы
if (statuses && Array.isArray(statuses)) {
// Удаляем старые статусы
await FormStatus.destroy({
where: { form_id: form.id },
transaction
});
// Создаем новые статусы
for (let i = 0; i < statuses.length; i++) {
const status = statuses[i];
await FormStatus.create({
form_id: form.id,
name: status.name,
color: status.color || '#e5e7eb',
position: i,
is_initial: i === 0 || status.is_initial,
is_final: i === statuses.length - 1 || status.is_final
}, { transaction });
}
}
await transaction.commit();
// Возвращаем обновленную форму
const updatedForm = await Form.findByPk(form.id, {
include: [
{ model: FormField, as: 'fields', order: [['position', 'ASC']] },
{ model: FormStatus, as: 'statuses', order: [['position', 'ASC']] }
]
});
res.json({
success: true,
data: updatedForm,
message: 'Форма успешно обновлена'
});
} catch (error) {
await transaction.rollback();
console.error('Ошибка обновления формы:', error);
res.status(400).json({
success: false,
error: error.message || 'Ошибка обновления формы'
});
}
};
module.exports = {
getForms,
createForm,
updateForm,
// ... другие методы
};
```
### ✅ Критерии завершения Этапа 2
**Функциональные требования:**
1. ✅ Создание форм с различными типами полей
2. ✅ Валидация полей согласно их типам
3. ✅ Настройка статусов форм с цветами
4. ✅ Правила переходов между статусами
5. ✅ Создание задач на основе форм
6. ✅ Назначение ответственных за задачи
7. ✅ Заполнение и редактирование полей задач
8. ✅ История изменений для history-number полей
9. ✅ Пагинация для всех списков
10. ✅ Фильтрация и поиск форм/задач
**Продолжение следует в Этапе 3...**
---
Хотите ли вы, чтобы я продолжил с детальным описанием Этапов 3 и 4? Это будет включать:
- Канбан доски с drag & drop
- Систему комментариев с упоминаниями
- Файловые вложения
- Real-time уведомления
- Отчетность и аналитику