Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.

This commit is contained in:
2026-04-17 14:37:36 +03:00
parent eb773c5531
commit 0a8a42d69b
447 changed files with 70696 additions and 492 deletions

View File

92
backend/tickets/models.py Normal file
View File

@@ -0,0 +1,92 @@
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List
from uuid import UUID
from datetime import datetime
VALID_STATUSES = {"open", "waiting_on_customer", "waiting_on_staff", "resolved", "closed"}
VALID_PRIORITIES = {"low", "medium", "high", "urgent"}
VALID_OPENED_VIA = {"app", "email", "phone", "staff"}
VALID_SENDERS = {"staff", "customer"}
class TicketCreate(BaseModel):
customer_id: str
customer_name: Optional[str] = None
subject: str = Field(..., max_length=500)
device_id: Optional[str] = None
device_serial: Optional[str] = None
opened_via: Optional[str] = None
priority: Optional[str] = None
@field_validator("priority")
@classmethod
def check_priority(cls, v):
if v is not None and v not in VALID_PRIORITIES:
raise ValueError(f"priority must be one of {VALID_PRIORITIES}")
return v
class TicketUpdate(BaseModel):
status: Optional[str] = None
priority: Optional[str] = None
device_id: Optional[str] = None
device_serial: Optional[str] = None
@field_validator("status")
@classmethod
def check_status(cls, v):
if v is not None and v not in VALID_STATUSES:
raise ValueError(f"status must be one of {VALID_STATUSES}")
return v
class MessageCreate(BaseModel):
sender_type: str
sender_id: str
sender_name: Optional[str] = None
body: str
is_internal: bool = False
@field_validator("sender_type")
@classmethod
def check_sender_type(cls, v):
if v not in VALID_SENDERS:
raise ValueError(f"sender_type must be one of {VALID_SENDERS}")
return v
class EscalateIn(BaseModel):
entry_id: UUID
class MessageOut(BaseModel):
id: UUID
sender_type: str
sender_id: str
sender_name: Optional[str]
body: str
is_internal: bool
created_at: datetime
model_config = {"from_attributes": True}
class TicketOut(BaseModel):
id: UUID
customer_id: str
customer_name: Optional[str]
device_id: Optional[str]
device_serial: Optional[str]
subject: str
status: str
priority: Optional[str]
opened_via: Optional[str]
linked_entry_id: Optional[UUID]
created_at: datetime
updated_at: datetime
messages: List[MessageOut] = []
model_config = {"from_attributes": True}
class TicketListResponse(BaseModel):
data: List[TicketOut]
pagination: dict

46
backend/tickets/orm.py Normal file
View File

@@ -0,0 +1,46 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, String, Text, DateTime, Boolean, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from database.postgres import Base
def _now():
return datetime.now(timezone.utc)
class SupportTicket(Base):
__tablename__ = "support_tickets"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
customer_id = Column(String(128), nullable=False) # Firestore ID (moves to UUID when customers migrate)
customer_name = Column(String(255), nullable=True) # denormalized snapshot
device_id = Column(String(128), nullable=True) # Firestore ID
device_serial = Column(String(64), nullable=True) # denormalized snapshot
subject = Column(String(500), nullable=False)
status = Column(String(30), nullable=False, default="open")
# open | waiting_on_customer | waiting_on_staff | resolved | closed
priority = Column(String(10), nullable=True) # low | medium | high | urgent
opened_via = Column(String(20), nullable=True) # app | email | phone | staff
linked_entry_id = Column(UUID(as_uuid=True), ForeignKey("crm_entries.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
messages = relationship("TicketMessage", back_populates="ticket",
cascade="all, delete-orphan", order_by="TicketMessage.created_at", lazy="noload")
class TicketMessage(Base):
__tablename__ = "ticket_messages"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ticket_id = Column(UUID(as_uuid=True), ForeignKey("support_tickets.id", ondelete="CASCADE"), nullable=False)
sender_type = Column(String(10), nullable=False) # 'staff' | 'customer'
sender_id = Column(String(128), nullable=False)
sender_name = Column(String(255), nullable=True)
body = Column(Text, nullable=False)
is_internal = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
ticket = relationship("SupportTicket", back_populates="messages")

87
backend/tickets/router.py Normal file
View File

@@ -0,0 +1,87 @@
from fastapi import APIRouter, Depends, Query
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from database.postgres import get_pg_session
from auth.dependencies import require_permission
from auth.models import TokenPayload
from tickets import service
from tickets.models import TicketCreate, TicketUpdate, MessageCreate, EscalateIn, TicketOut, TicketListResponse
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
@router.get("", response_model=TicketListResponse)
async def list_tickets(
status: str | None = Query(None),
priority: str | None = Query(None),
customer_id: str | None = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(25, ge=1, le=100),
db: AsyncSession = Depends(get_pg_session),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
rows, total = await service.list_tickets(db, status, priority, customer_id, page, limit)
return {"data": rows, "pagination": {"page": page, "limit": limit, "total": total}}
@router.get("/by-customer/{customer_id}", response_model=list[TicketOut])
async def list_by_customer(
customer_id: str,
db: AsyncSession = Depends(get_pg_session),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
return await service.list_by_customer(db, customer_id)
@router.get("/by-device/{device_id}", response_model=list[TicketOut])
async def list_by_device(
device_id: str,
db: AsyncSession = Depends(get_pg_session),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
return await service.list_by_device(db, device_id)
@router.get("/{ticket_id}", response_model=TicketOut)
async def get_ticket(
ticket_id: UUID,
db: AsyncSession = Depends(get_pg_session),
_user: TokenPayload = Depends(require_permission("crm", "view")),
):
return await service.get_ticket(db, ticket_id)
@router.post("", response_model=TicketOut, status_code=201)
async def create_ticket(
body: TicketCreate,
db: AsyncSession = Depends(get_pg_session),
_user: TokenPayload = Depends(require_permission("crm", "add")),
):
return await service.create_ticket(db, body)
@router.patch("/{ticket_id}", response_model=TicketOut)
async def update_ticket(
ticket_id: UUID, body: TicketUpdate,
db: AsyncSession = Depends(get_pg_session),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
return await service.update_ticket(db, ticket_id, body)
@router.post("/{ticket_id}/messages", response_model=TicketOut)
async def add_message(
ticket_id: UUID, body: MessageCreate,
db: AsyncSession = Depends(get_pg_session),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
return await service.add_message(db, ticket_id, body)
@router.post("/{ticket_id}/escalate", response_model=TicketOut)
async def escalate(
ticket_id: UUID, body: EscalateIn,
db: AsyncSession = Depends(get_pg_session),
_user: TokenPayload = Depends(require_permission("crm", "edit")),
):
return await service.escalate_to_issue(db, ticket_id, body.entry_id)

View File

@@ -0,0 +1,91 @@
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from tickets.orm import SupportTicket, TicketMessage
from tickets.models import TicketCreate, TicketUpdate, MessageCreate
from shared.exceptions import NotFoundError
async def create_ticket(db: AsyncSession, data: TicketCreate) -> SupportTicket:
ticket = SupportTicket(**data.model_dump())
db.add(ticket)
await db.commit()
return await _get_ticket(db, ticket.id, include_internal=True)
async def get_ticket(db: AsyncSession, ticket_id: uuid.UUID, include_internal: bool = True) -> SupportTicket:
return await _get_ticket(db, ticket_id, include_internal)
async def list_tickets(
db: AsyncSession,
status: str | None, priority: str | None, customer_id: str | None,
page: int, limit: int,
) -> tuple[list[SupportTicket], int]:
limit = min(100, max(1, limit))
offset = (max(1, page) - 1) * limit
q = select(SupportTicket).options(selectinload(SupportTicket.messages))
if status: q = q.where(SupportTicket.status == status)
if priority: q = q.where(SupportTicket.priority == priority)
if customer_id: q = q.where(SupportTicket.customer_id == customer_id)
total = (await db.execute(select(func.count()).select_from(q.subquery()))).scalar()
rows = (await db.execute(q.order_by(SupportTicket.created_at.desc()).limit(limit).offset(offset))).scalars().all()
return rows, total
async def update_ticket(db: AsyncSession, ticket_id: uuid.UUID, data: TicketUpdate) -> SupportTicket:
ticket = await _get_ticket(db, ticket_id)
for field, value in data.model_dump(exclude_unset=True).items():
setattr(ticket, field, value)
await db.commit()
return await _get_ticket(db, ticket_id)
async def add_message(db: AsyncSession, ticket_id: uuid.UUID, data: MessageCreate) -> SupportTicket:
ticket = await _get_ticket(db, ticket_id)
msg = TicketMessage(ticket_id=ticket_id, **data.model_dump())
db.add(msg)
# Auto-advance ticket status based on who replied (skip if already resolved/closed)
if ticket.status not in ("resolved", "closed"):
if data.sender_type == "staff" and not data.is_internal:
ticket.status = "waiting_on_customer"
elif data.sender_type == "customer":
ticket.status = "waiting_on_staff"
await db.commit()
return await _get_ticket(db, ticket_id)
async def escalate_to_issue(db: AsyncSession, ticket_id: uuid.UUID, entry_id: uuid.UUID) -> SupportTicket:
ticket = await _get_ticket(db, ticket_id)
ticket.linked_entry_id = entry_id
await db.commit()
return await _get_ticket(db, ticket_id)
async def list_by_customer(db: AsyncSession, customer_id: str) -> list[SupportTicket]:
q = select(SupportTicket).options(selectinload(SupportTicket.messages)).where(
SupportTicket.customer_id == customer_id
).order_by(SupportTicket.created_at.desc())
return (await db.execute(q)).scalars().all()
async def list_by_device(db: AsyncSession, device_id: str) -> list[SupportTicket]:
q = select(SupportTicket).options(selectinload(SupportTicket.messages)).where(
SupportTicket.device_id == device_id
).order_by(SupportTicket.created_at.desc())
return (await db.execute(q)).scalars().all()
async def _get_ticket(db: AsyncSession, ticket_id: uuid.UUID, include_internal: bool = True) -> SupportTicket:
q = select(SupportTicket).options(selectinload(SupportTicket.messages)).where(SupportTicket.id == ticket_id)
result = (await db.execute(q)).scalar_one_or_none()
if not result:
raise NotFoundError("Ticket")
if not include_internal:
result.messages = [m for m in result.messages if not m.is_internal]
return result