Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
0
backend/tickets/__init__.py
Normal file
0
backend/tickets/__init__.py
Normal file
92
backend/tickets/models.py
Normal file
92
backend/tickets/models.py
Normal 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
46
backend/tickets/orm.py
Normal 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
87
backend/tickets/router.py
Normal 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)
|
||||
91
backend/tickets/service.py
Normal file
91
backend/tickets/service.py
Normal 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
|
||||
Reference in New Issue
Block a user