- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
47 KiB
Детальный промт для создания мультитенантной системы управления рабочими процессами
Инструкции по общению
ВАЖНО: После создания приложения всё дальнейшее общение должно происходить на русском языке. Интерфейс приложения также должен быть полностью на русском языке.
Описание проекта
Создай корпоративную мультитенантную систему управления рабочими процессами с расширенным функционалом. Система должна поддерживать множество организаций с полной изоляцией данных.
ЭТАП 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
Функциональные требования:
- ✅ PostgreSQL база данных настроена и работает
- ✅ Таблицы organizations, users, user_sessions созданы
- ✅ JWT авторизация с access и refresh токенами работает
- ✅ Регистрация организации с email подтверждением
- ✅ Вход в систему с проверкой организации
- ✅ Middleware для мультитенантности изолирует данные
- ✅ Rate limiting защищает от брутфорса
- ✅ Админ может добавлять пользователей в организацию
- ✅ Frontend формы регистрации и входа работают
- ✅ Docker контейнеры запускаются и работают
Технические требования:
- ✅ API endpoints отвечают согласно спецификации
- ✅ Валидация входных данных на backend и frontend
- ✅ Ошибки обрабатываются и логируются
- ✅ CORS настроен правильно
- ✅ Environment переменные используются
- ✅ Пароли хэшируются с bcrypt
- ✅ Email отправка работает
- ✅ Мультитенантность изолирует данные между организациями
Тестирование:
- Зарегистрируй 2 разные организации
- Создай пользователей в каждой организации
- Убедись, что пользователи видят только данные своей организации
- Проверь, что неавторизованные запросы блокируются
- Проверь работу 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
Функциональные требования:
- ✅ Создание форм с различными типами полей
- ✅ Валидация полей согласно их типам
- ✅ Настройка статусов форм с цветами
- ✅ Правила переходов между статусами
- ✅ Создание задач на основе форм
- ✅ Назначение ответственных за задачи
- ✅ Заполнение и редактирование полей задач
- ✅ История изменений для history-number полей
- ✅ Пагинация для всех списков
- ✅ Фильтрация и поиск форм/задач
Продолжение следует в Этапе 3...
Хотите ли вы, чтобы я продолжил с детальным описанием Этапов 3 и 4? Это будет включать:
- Канбан доски с drag & drop
- Систему комментариев с упоминаниями
- Файловые вложения
- Real-time уведомления
- Отчетность и аналитику