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

47 KiB
Raw Permalink Blame History

Детальный промт для создания мультитенантной системы управления рабочими процессами

Инструкции по общению

ВАЖНО: После создания приложения всё дальнейшее общение должно происходить на русском языке. Интерфейс приложения также должен быть полностью на русском языке.

Описание проекта

Создай корпоративную мультитенантную систему управления рабочими процессами с расширенным функционалом. Система должна поддерживать множество организаций с полной изоляцией данных.


ЭТАП 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

Создай следующие таблицы:

-- Таблица организаций
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):

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):

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):

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):

// Входные данные:
{
    "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):

// Входные данные:
{
    "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):

// Входные данные:
{
    "refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}

// Ответ:
{
    "success": true,
    "tokens": {
        "accessToken": "new-access-token",
        "refreshToken": "new-refresh-token",
        "expiresIn": 900
    }
}

🔒 Безопасность - Детальная реализация

Хэширование паролей (services/auth.service.js):

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):

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):

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:

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:

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):

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: СИСТЕМА ФОРМ И ЗАДАЧ

🎯 Цель этапа

Создать систему динамических форм с различными типами полей и систему задач на основе этих форм.

🗄️ Расширение схемы базы данных

-- Таблица форм
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):

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):

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 уведомления
  • Отчетность и аналитику