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