# Детальный промт для создания мультитенантной системы управления рабочими процессами ## Инструкции по общению **ВАЖНО**: После создания приложения **всё дальнейшее общение должно происходить на русском языке**. Интерфейс приложения также должен быть полностью на русском языке. ## Описание проекта Создай корпоративную мультитенантную систему управления рабочими процессами с расширенным функционалом. Система должна поддерживать множество организаций с полной изоляцией данных. --- ## ЭТАП 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: `

Добро пожаловать в ${organizationName}!

Здравствуйте, ${firstName}!

Для завершения регистрации перейдите по ссылке:

Подтвердить email

Ссылка действительна в течение 24 часов.

` }; 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: `

Вы приглашены в ${organizationName}

Здравствуйте, ${firstName}!

${inviterName} пригласил вас присоединиться к команде.

Временный пароль: ${tempPassword}

Обязательно смените пароль после первого входа.

Войти в систему ` }; 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 (

Создание организации

{errors.organizationName && {errors.organizationName}}
{errors.organizationSlug && {errors.organizationSlug}}
{errors.adminEmail && {errors.adminEmail}}
{errors.adminFirstName && {errors.adminFirstName}}
{errors.adminLastName && {errors.adminLastName}}
{errors.adminPassword && {errors.adminPassword}}
{errors.confirmPassword && {errors.confirmPassword}}
{errors.submit &&
{errors.submit}
}
); }; 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 уведомления - Отчетность и аналитику