diff --git a/backend/app/api/endpoints/chat.py b/backend/app/api/endpoints/chat.py
new file mode 100644
index 0000000..4e2c155
--- /dev/null
+++ b/backend/app/api/endpoints/chat.py
@@ -0,0 +1,141 @@
+from fastapi import APIRouter, Depends, HTTPException, WebSocket, UploadFile, File
+from sqlalchemy.orm import Session
+from typing import List
+import os
+import aiofiles
+import uuid
+
+from app.database import get_db
+from app.models.user import User
+from app.core.auth import get_current_user
+from app.schemas.chat import Chat, Message, ChatFile
+from app.websockets.chat import handle_chat_connection
+
+router = APIRouter()
+
+# Путь для сохранения файлов
+UPLOAD_DIR = "uploads/chat_files"
+os.makedirs(UPLOAD_DIR, exist_ok=True)
+
+@router.websocket("/ws/chat")
+async def websocket_endpoint(websocket: WebSocket, db: Session = Depends(get_db)):
+ await handle_chat_connection(websocket, db)
+
+@router.post("/files/")
+async def upload_file(
+ file: UploadFile = File(...),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ try:
+ # Генерируем уникальное имя файла
+ file_extension = os.path.splitext(file.filename)[1]
+ unique_filename = f"{uuid.uuid4()}{file_extension}"
+ file_path = os.path.join(UPLOAD_DIR, unique_filename)
+
+ # Сохраняем файл
+ async with aiofiles.open(file_path, 'wb') as out_file:
+ content = await file.read()
+ await out_file.write(content)
+
+ return {
+ "filename": file.filename,
+ "saved_path": file_path,
+ "size": len(content)
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+@router.get("/messages/", response_model=List[Message])
+def get_messages(
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ # Получаем чат пользователя
+ chat = db.query(Chat).filter(
+ (Chat.employee_id == current_user.id) |
+ (Chat.admin_id == current_user.id)
+ ).first()
+
+ if not chat:
+ return []
+
+ # Получаем сообщения
+ messages = db.query(Message).filter(Message.chat_id == chat.id).all()
+ return messages
+
+@router.get("/unread-count/")
+def get_unread_count(
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ # Получаем чат пользователя
+ chat = db.query(Chat).filter(
+ (Chat.employee_id == current_user.id) |
+ (Chat.admin_id == current_user.id)
+ ).first()
+
+ if not chat:
+ return {"unread_count": 0}
+
+ # Считаем непрочитанные сообщения
+ unread_count = db.query(Message).filter(
+ Message.chat_id == chat.id,
+ Message.sender_id != current_user.id,
+ Message.is_read == False
+ ).count()
+
+ return {"unread_count": unread_count}
+
+@router.get("/admin/chats/", response_model=List[Chat])
+def get_admin_chats(
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ if not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Not authorized")
+
+ # Получаем все чаты с последними сообщениями и количеством непрочитанных
+ chats = db.query(Chat).all()
+
+ # Для каждого чата добавляем дополнительную информацию
+ for chat in chats:
+ # Последнее сообщение
+ last_message = db.query(Message)\
+ .filter(Message.chat_id == chat.id)\
+ .order_by(Message.created_at.desc())\
+ .first()
+ chat.last_message = last_message
+
+ # Количество непрочитанных сообщений
+ unread_count = db.query(Message)\
+ .filter(
+ Message.chat_id == chat.id,
+ Message.sender_id != current_user.id,
+ Message.is_read == False
+ ).count()
+ chat.unread_count = unread_count
+
+ return chats
+
+@router.get("/messages/{chat_id}/", response_model=List[Message])
+def get_chat_messages(
+ chat_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ # Проверяем доступ к чату
+ chat = db.query(Chat).filter(Chat.id == chat_id).first()
+ if not chat:
+ raise HTTPException(status_code=404, detail="Chat not found")
+
+ if not current_user.is_admin and chat.employee_id != current_user.id:
+ raise HTTPException(status_code=403, detail="Not authorized")
+
+ # Получаем сообщения чата
+ messages = db.query(Message)\
+ .filter(Message.chat_id == chat_id)\
+ .order_by(Message.created_at.asc())\
+ .all()
+
+ return messages
\ No newline at end of file
diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py
new file mode 100644
index 0000000..a3a097f
--- /dev/null
+++ b/backend/app/core/auth.py
@@ -0,0 +1,57 @@
+from fastapi import Depends, HTTPException, status, WebSocket
+from fastapi.security import OAuth2PasswordBearer
+from jose import JWTError, jwt
+from sqlalchemy.orm import Session
+from typing import Optional
+
+from app.core.config import settings
+from app.database import get_db
+from app.models.user import User
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
+
+def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
+ credentials_exception = HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Could not validate credentials",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ try:
+ payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
+ email: str = payload.get("sub")
+ if email is None:
+ raise credentials_exception
+ except JWTError:
+ raise credentials_exception
+
+ user = db.query(User).filter(User.email == email).first()
+ if user is None:
+ raise credentials_exception
+ return user
+
+async def get_current_user_ws(websocket: WebSocket, db: Session = Depends(get_db)) -> Optional[User]:
+ try:
+ # Получаем токен из параметров запроса
+ token = websocket.query_params.get("token")
+ if not token:
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
+ return None
+
+ # Проверяем токен
+ payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
+ email: str = payload.get("sub")
+ if email is None:
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
+ return None
+
+ # Получаем пользователя
+ user = db.query(User).filter(User.email == email).first()
+ if user is None:
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
+ return None
+
+ return user
+
+ except JWTError:
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
+ return None
\ No newline at end of file
diff --git a/backend/app/core/scheduler.py b/backend/app/core/scheduler.py
new file mode 100644
index 0000000..c6e18d7
--- /dev/null
+++ b/backend/app/core/scheduler.py
@@ -0,0 +1,19 @@
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+from apscheduler.triggers.cron import CronTrigger
+from app.tasks.cleanup import cleanup_old_messages
+
+scheduler = AsyncIOScheduler()
+
+def setup_scheduler():
+ """Настраивает планировщик задач"""
+
+ # Запускаем очистку старых сообщений каждый день в полночь
+ scheduler.add_job(
+ cleanup_old_messages,
+ trigger=CronTrigger(hour=0, minute=0),
+ id='cleanup_old_messages',
+ name='Cleanup old messages and files',
+ replace_existing=True
+ )
+
+ scheduler.start()
\ No newline at end of file
diff --git a/backend/app/main.py b/backend/app/main.py
index a7de79c..edc9860 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,40 +1,38 @@
"""Main application module"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from . import models
-from .routers import admin, employees, requests, auth, statistics
+from app.api.api import api_router
+from app.core.config import settings
+from app.core.scheduler import setup_scheduler
+from app.websockets.chat import handle_chat_connection
app = FastAPI(
- # Включаем автоматическое перенаправление со слэшем
- redirect_slashes=True,
- # Добавляем описание API
- title="Support System API",
- description="API для системы поддержки",
- version="1.0.0"
+ title=settings.project_name,
+ openapi_url=f"{settings.api_v1_str}/openapi.json"
)
-# CORS configuration
-origins = [
- "http://localhost",
- "http://localhost:8080",
- "http://localhost:5173",
- "http://127.0.0.1:5173",
- "http://127.0.0.1:8080",
- "http://185.139.70.62", # Добавляем ваш production домен
-]
-
+# Настройка CORS
app.add_middleware(
CORSMiddleware,
- allow_origins=origins,
+ allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
- expose_headers=["*"]
)
-# Include routers
-app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
-app.include_router(employees.router, prefix="/api/employees", tags=["employees"])
-app.include_router(requests.router, prefix="/api/requests", tags=["requests"])
-app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
-app.include_router(statistics.router, prefix="/api/statistics", tags=["statistics"])
\ No newline at end of file
+# Подключаем роутеры
+app.include_router(api_router, prefix=settings.api_v1_str)
+
+# WebSocket для чата
+app.add_api_websocket_route("/ws/chat", handle_chat_connection)
+
+@app.on_event("startup")
+async def startup_event():
+ """Действия при запуске приложения"""
+ setup_scheduler()
+
+@app.on_event("shutdown")
+async def shutdown_event():
+ """Действия при остановке приложения"""
+ from app.core.scheduler import scheduler
+ scheduler.shutdown()
\ No newline at end of file
diff --git a/backend/app/models/chat.py b/backend/app/models/chat.py
new file mode 100644
index 0000000..9bd7251
--- /dev/null
+++ b/backend/app/models/chat.py
@@ -0,0 +1,46 @@
+from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, Text
+from sqlalchemy.sql import func
+from sqlalchemy.orm import relationship
+
+from app.database import Base
+
+class Chat(Base):
+ __tablename__ = "chats"
+
+ id = Column(Integer, primary_key=True, index=True)
+ employee_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
+ admin_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+ # Отношения
+ employee = relationship("User", foreign_keys=[employee_id], back_populates="employee_chats")
+ admin = relationship("User", foreign_keys=[admin_id], back_populates="admin_chats")
+ messages = relationship("Message", back_populates="chat", cascade="all, delete-orphan")
+
+class Message(Base):
+ __tablename__ = "messages"
+
+ id = Column(Integer, primary_key=True, index=True)
+ chat_id = Column(Integer, ForeignKey("chats.id", ondelete="CASCADE"), nullable=False)
+ sender_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ content = Column(Text, nullable=False)
+ is_read = Column(Boolean, default=False)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+ # Отношения
+ chat = relationship("Chat", back_populates="messages")
+ sender = relationship("User", back_populates="sent_messages")
+ files = relationship("ChatFile", back_populates="message", cascade="all, delete-orphan")
+
+class ChatFile(Base):
+ __tablename__ = "chat_files"
+
+ id = Column(Integer, primary_key=True, index=True)
+ message_id = Column(Integer, ForeignKey("messages.id", ondelete="CASCADE"), nullable=False)
+ file_name = Column(String(255), nullable=False)
+ file_path = Column(String(255), nullable=False)
+ file_size = Column(Integer, nullable=False)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+ # Отношения
+ message = relationship("Message", back_populates="files")
\ No newline at end of file
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
new file mode 100644
index 0000000..d963cd3
--- /dev/null
+++ b/backend/app/models/user.py
@@ -0,0 +1,19 @@
+from sqlalchemy import Boolean, Column, Integer, String
+from sqlalchemy.orm import relationship
+
+from app.database import Base
+
+class User(Base):
+ __tablename__ = "users"
+
+ id = Column(Integer, primary_key=True, index=True)
+ email = Column(String, unique=True, index=True)
+ full_name = Column(String)
+ hashed_password = Column(String)
+ is_active = Column(Boolean, default=True)
+ is_admin = Column(Boolean, default=False)
+
+ # Отношения для чата
+ employee_chats = relationship("Chat", foreign_keys="[Chat.employee_id]", back_populates="employee")
+ admin_chats = relationship("Chat", foreign_keys="[Chat.admin_id]", back_populates="admin")
+ sent_messages = relationship("Message", back_populates="sender")
\ No newline at end of file
diff --git a/backend/app/schemas/chat.py b/backend/app/schemas/chat.py
new file mode 100644
index 0000000..327fa6c
--- /dev/null
+++ b/backend/app/schemas/chat.py
@@ -0,0 +1,73 @@
+from pydantic import BaseModel
+from datetime import datetime
+from typing import List, Optional
+from app.schemas.user import User
+
+class ChatFileBase(BaseModel):
+ file_name: str
+ file_size: int
+
+class ChatFileCreate(ChatFileBase):
+ pass
+
+class ChatFile(ChatFileBase):
+ id: int
+ message_id: int
+ file_path: str
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
+
+class MessageBase(BaseModel):
+ content: str
+
+class MessageCreate(MessageBase):
+ pass
+
+class Message(MessageBase):
+ id: int
+ chat_id: int
+ sender_id: int
+ is_read: bool
+ created_at: datetime
+ files: List[ChatFile] = []
+
+ class Config:
+ from_attributes = True
+
+class ChatBase(BaseModel):
+ employee_id: int
+ admin_id: int
+
+class ChatCreate(ChatBase):
+ pass
+
+class Chat(ChatBase):
+ id: int
+ created_at: datetime
+ employee: User
+ admin: User
+ last_message: Optional[Message] = None
+ unread_count: Optional[int] = 0
+
+ class Config:
+ from_attributes = True
+
+# Схемы для WebSocket сообщений
+class WSMessage(BaseModel):
+ type: str
+ content: Optional[str] = None
+ message_ids: Optional[List[int]] = None
+ files: Optional[List[dict]] = None
+
+class WSResponse(BaseModel):
+ type: str
+ id: Optional[int] = None
+ sender_id: Optional[int] = None
+ content: Optional[str] = None
+ created_at: Optional[datetime] = None
+ is_read: Optional[bool] = None
+ message_ids: Optional[List[int]] = None
+ files: Optional[List[ChatFile]] = None
+ error: Optional[str] = None
\ No newline at end of file
diff --git a/backend/app/tasks/cleanup.py b/backend/app/tasks/cleanup.py
new file mode 100644
index 0000000..f23be01
--- /dev/null
+++ b/backend/app/tasks/cleanup.py
@@ -0,0 +1,38 @@
+import os
+from datetime import datetime, timedelta
+from sqlalchemy.orm import Session
+from app.database import SessionLocal
+from app.models.chat import Message, ChatFile
+
+def cleanup_old_messages():
+ """Удаляет сообщения и файлы старше 1 месяца"""
+ db = SessionLocal()
+ try:
+ # Определяем дату, до которой нужно удалить сообщения
+ cutoff_date = datetime.utcnow() - timedelta(days=30)
+
+ # Получаем файлы, которые нужно удалить
+ files_to_delete = db.query(ChatFile)\
+ .join(Message)\
+ .filter(Message.created_at < cutoff_date)\
+ .all()
+
+ # Удаляем физические файлы
+ for file in files_to_delete:
+ try:
+ if os.path.exists(file.file_path):
+ os.remove(file.file_path)
+ except Exception as e:
+ print(f"Error deleting file {file.file_path}: {e}")
+
+ # Удаляем старые сообщения (каскадно удалятся и записи о файлах)
+ db.query(Message)\
+ .filter(Message.created_at < cutoff_date)\
+ .delete(synchronize_session=False)
+
+ db.commit()
+ except Exception as e:
+ print(f"Error during cleanup: {e}")
+ db.rollback()
+ finally:
+ db.close()
\ No newline at end of file
diff --git a/backend/app/websockets/chat.py b/backend/app/websockets/chat.py
new file mode 100644
index 0000000..aa85468
--- /dev/null
+++ b/backend/app/websockets/chat.py
@@ -0,0 +1,112 @@
+from fastapi import WebSocket, WebSocketDisconnect, Depends
+from typing import Dict, List, Optional
+from datetime import datetime
+import json
+
+from app.core.auth import get_current_user_ws
+from app.models.user import User
+from app.models.chat import Chat, Message, ChatFile
+from app.database import get_db
+from sqlalchemy.orm import Session
+
+class ConnectionManager:
+ def __init__(self):
+ # Хранение активных соединений: {user_id: WebSocket}
+ self.active_connections: Dict[int, WebSocket] = {}
+
+ async def connect(self, websocket: WebSocket, user_id: int):
+ await websocket.accept()
+ self.active_connections[user_id] = websocket
+
+ def disconnect(self, user_id: int):
+ if user_id in self.active_connections:
+ del self.active_connections[user_id]
+
+ async def send_personal_message(self, message: dict, user_id: int):
+ if user_id in self.active_connections:
+ await self.active_connections[user_id].send_json(message)
+
+ def is_connected(self, user_id: int) -> bool:
+ return user_id in self.active_connections
+
+manager = ConnectionManager()
+
+async def handle_chat_connection(
+ websocket: WebSocket,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user_ws)
+):
+ await manager.connect(websocket, current_user.id)
+ try:
+ while True:
+ data = await websocket.receive_json()
+
+ # Обработка различных типов сообщений
+ message_type = data.get('type')
+
+ if message_type == 'message':
+ # Создание нового сообщения
+ chat = db.query(Chat).filter(
+ (Chat.employee_id == current_user.id) |
+ (Chat.admin_id == current_user.id)
+ ).first()
+
+ if not chat:
+ # Создаем новый чат для сотрудника
+ admin = db.query(User).filter(User.is_admin == True).first()
+ chat = Chat(employee_id=current_user.id, admin_id=admin.id)
+ db.add(chat)
+ db.commit()
+
+ message = Message(
+ chat_id=chat.id,
+ sender_id=current_user.id,
+ content=data['content']
+ )
+ db.add(message)
+ db.commit()
+
+ # Определяем получателя
+ recipient_id = chat.admin_id if current_user.id == chat.employee_id else chat.employee_id
+
+ # Отправляем сообщение получателю
+ message_data = {
+ 'type': 'message',
+ 'id': message.id,
+ 'sender_id': current_user.id,
+ 'content': message.content,
+ 'created_at': message.created_at.isoformat(),
+ 'is_read': False
+ }
+
+ if manager.is_connected(recipient_id):
+ await manager.send_personal_message(message_data, recipient_id)
+
+ elif message_type == 'read':
+ # Отмечаем сообщения как прочитанные
+ message_ids = data.get('message_ids', [])
+ db.query(Message).filter(Message.id.in_(message_ids)).update(
+ {Message.is_read: True},
+ synchronize_session=False
+ )
+ db.commit()
+
+ # Отправляем подтверждение прочтения
+ chat = db.query(Chat).filter(
+ (Chat.employee_id == current_user.id) |
+ (Chat.admin_id == current_user.id)
+ ).first()
+
+ if chat:
+ recipient_id = chat.admin_id if current_user.id == chat.employee_id else chat.employee_id
+ if manager.is_connected(recipient_id):
+ await manager.send_personal_message({
+ 'type': 'read_confirmation',
+ 'message_ids': message_ids
+ }, recipient_id)
+
+ except WebSocketDisconnect:
+ manager.disconnect(current_user.id)
+ except Exception as e:
+ print(f"Error in WebSocket connection: {e}")
+ manager.disconnect(current_user.id)
\ No newline at end of file
diff --git a/backend/requirements.txt b/backend/requirements.txt
index b0104ac..0118e36 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -3,7 +3,7 @@ uvicorn==0.27.1
sqlalchemy==2.0.27
pydantic==2.5.2
pydantic-settings==2.2.1
-python-multipart==0.0.9
+python-multipart==0.0.6
python-jose[cryptography]==3.3.0
passlib[bcrypt]>=1.7.4
bcrypt>=4.0.1
@@ -15,3 +15,6 @@ pytest==8.0.0
httpx==0.26.0
requests>=2.26.0
aiogram==3.4.1
+websockets==12.0
+aiofiles==23.2.1
+APScheduler==3.10.4
diff --git a/frontend/src/components/ChatModal.vue b/frontend/src/components/ChatModal.vue
new file mode 100644
index 0000000..e74e833
--- /dev/null
+++ b/frontend/src/components/ChatModal.vue
@@ -0,0 +1,367 @@
+
+ {{ message.content }}
+ {{ title }}
+
+ {{ message }}
+
+ {{ chat.last_message ? truncateMessage(chat.last_message.content) : 'Нет сообщений' }}
+
+ Нет активных чатов
+
+ {{ chat.employee.email }}
+ {{ message.content }}Чат поддержки
+
+ Чаты сотрудников
+
+ {{ chat.employee.full_name }}
+
+
+ {{ chat.employee.full_name }}
+
+
@@ -323,12 +331,29 @@
@close="showNotification = false"
/>
+
+
+