feat(l1): internal_ticket_service with CRUD + status transitions
create_ticket, update_status (sets resolved_at on resolve), get_ticket, list_tickets_for_account (status filter, account-scoped), promote_to_psa. Used by L1 intake when account has no PSA integration configured. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
90
backend/app/services/internal_ticket_service.py
Normal file
90
backend/app/services/internal_ticket_service.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""CRUD + status transitions for internal_tickets (the no-PSA fallback ticket model)."""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.internal_ticket import InternalTicket
|
||||
|
||||
|
||||
async def create_ticket(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
account_id: UUID,
|
||||
created_by_user_id: UUID,
|
||||
problem_statement: str,
|
||||
customer_name: Optional[str] = None,
|
||||
customer_contact: Optional[str] = None,
|
||||
) -> InternalTicket:
|
||||
"""Create a new internal ticket in 'open' status."""
|
||||
ticket = InternalTicket(
|
||||
account_id=account_id,
|
||||
created_by_user_id=created_by_user_id,
|
||||
problem_statement=problem_statement,
|
||||
customer_name=customer_name,
|
||||
customer_contact=customer_contact,
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush()
|
||||
return ticket
|
||||
|
||||
|
||||
async def update_status(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
ticket_id: UUID,
|
||||
status: str,
|
||||
resolution_notes: Optional[str] = None,
|
||||
assigned_user_id: Optional[UUID] = None,
|
||||
) -> InternalTicket:
|
||||
"""Transition a ticket to a new status. Sets resolved_at when status='resolved'."""
|
||||
ticket = await db.get(InternalTicket, ticket_id)
|
||||
if not ticket:
|
||||
raise ValueError(f"InternalTicket {ticket_id} not found")
|
||||
ticket.status = status
|
||||
if status == 'resolved':
|
||||
ticket.resolved_at = datetime.now(timezone.utc)
|
||||
if resolution_notes is not None:
|
||||
ticket.resolution_notes = resolution_notes
|
||||
if assigned_user_id is not None:
|
||||
ticket.assigned_user_id = assigned_user_id
|
||||
await db.flush()
|
||||
return ticket
|
||||
|
||||
|
||||
async def get_ticket(db: AsyncSession, ticket_id: UUID) -> Optional[InternalTicket]:
|
||||
"""Fetch a ticket by ID. Returns None if not found."""
|
||||
return await db.get(InternalTicket, ticket_id)
|
||||
|
||||
|
||||
async def list_tickets_for_account(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
account_id: UUID,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
) -> list[InternalTicket]:
|
||||
"""List tickets for an account, optionally filtered by status, newest first."""
|
||||
stmt = select(InternalTicket).where(InternalTicket.account_id == account_id)
|
||||
if status:
|
||||
stmt = stmt.where(InternalTicket.status == status)
|
||||
stmt = stmt.order_by(InternalTicket.created_at.desc()).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars())
|
||||
|
||||
|
||||
async def promote_to_psa(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
ticket_id: UUID,
|
||||
psa_ticket_id: str,
|
||||
) -> InternalTicket:
|
||||
"""Mark an internal ticket as promoted to PSA."""
|
||||
ticket = await db.get(InternalTicket, ticket_id)
|
||||
if not ticket:
|
||||
raise ValueError(f"InternalTicket {ticket_id} not found")
|
||||
ticket.psa_promoted_ticket_id = psa_ticket_id
|
||||
await db.flush()
|
||||
return ticket
|
||||
Reference in New Issue
Block a user