Files
replypier/backend/app/api/tickets.py

747 lines
29 KiB
Python

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.core.auth import check_permissions, get_current_user
from app.core.logger import get_logger
from app.database import get_db
from app.models.investigation import InvestigationRunType, InvestigationTrigger
from app.models.schemas.pagination import Pagination
from app.models.schemas.ticket import (
HypothesisOut,
InvestigateRequest,
InvestigationDetailResponse,
InvestigationEventOut,
InvestigationRunOut,
ProposalOut,
ProposalRejectRequest,
RCADocumentOut,
RcaUpdate,
TicketCommentCreate,
TicketCreate,
TicketDetailResponse,
TicketListItem,
TicketListResponse,
TicketOut,
TicketReopen,
TicketResolve,
TicketSettingsOut,
TicketSettingsUpdate,
TicketStats,
TicketUpdate,
TicketActivityOut,
)
from app.models.ticket import Ticket, TicketSource, TicketStatus
from app.models.ticket_activity import TicketActorType
from app.models.user import User
from app.repositories.chat import ChatRepository
from app.repositories.session_to_agent import SessionToAgentRepository
from app.services.ticket import TicketService
from app.services.ticket_access import check_ticketing_access
from app.services.ticket_events import emit_ticket_update
from app.core.params import PageNumber
logger = get_logger(__name__)
router = APIRouter()
MAX_PAGE_SIZE = 100
# A ticket the team has already settled. It can be reopened, but nothing may
# push it forward — notably no new AI run, which would otherwise churn a closed
# ticket and re-notify the customer. Mirrored by isReopenable in
# TicketDetailView.vue, which disables the Investigate button on these.
SETTLED_STATUSES = (
TicketStatus.RESOLVED.value,
TicketStatus.CLOSED.value,
TicketStatus.RESOLVED_PENDING_CONFIRMATION.value,
)
def require_any_permission(*permissions: str):
"""Any-of permission dependency (require_permissions is all-of)."""
async def checker(current_user: User = Depends(get_current_user)) -> User:
for permission in permissions:
if check_permissions(current_user, [permission]):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
return checker
view_tickets = require_any_permission("view_tickets", "manage_tickets")
manage_tickets = require_any_permission("manage_tickets")
# Approving/rejecting AI-proposed resolutions is a separate trust boundary
# from day-to-day ticket management.
approve_actions = require_any_permission("approve_ticket_actions")
def _service(db: Session) -> TicketService:
return TicketService(db)
def _list_item(service: TicketService, ticket: Ticket, has_active_run: bool, settings=None) -> TicketListItem:
return TicketListItem(
id=ticket.id,
ticket_number=ticket.ticket_number,
display_number=ticket.display_number,
title=ticket.title,
status=ticket.status,
priority=ticket.priority,
tags=ticket.tags,
assignee_user_id=ticket.assignee_user_id,
assignee_name=ticket.assignee.full_name if ticket.assignee else None,
ai_state=service.ai_state(ticket, has_active_run=has_active_run),
sla_due_at=service.sla_due_at(ticket, settings),
resolved_at=ticket.resolved_at,
created_at=ticket.created_at,
updated_at=ticket.updated_at,
)
def _ticket_out(service: TicketService, ticket: Ticket) -> TicketOut:
out = TicketOut.model_validate(ticket)
out.display_number = ticket.display_number
out.ai_state = service.ai_state(ticket)
out.sla_due_at = service.sla_due_at(ticket)
return out
def _activity_out(activity) -> TicketActivityOut:
out = TicketActivityOut.model_validate(activity)
if activity.actor_user is not None:
out.actor_name = activity.actor_user.full_name
elif str(activity.actor_type) == TicketActorType.AI.value:
out.actor_name = "ReplyPier AI"
return out
def _get_ticket_or_404(
db: Session, ticket_id: UUID, user: User, for_update: bool = False
) -> Ticket:
from app.repositories.ticket import TicketRepository
ticket = TicketRepository(db).get_by_id(
ticket_id, user.organization_id, for_update=for_update
)
if ticket is None:
raise HTTPException(status_code=404, detail="Ticket not found")
return ticket
@router.get("", response_model=TicketListResponse)
async def list_tickets(
status_filter: Optional[str] = Query(default=None, alias="status"),
priority: Optional[str] = None,
assignee_id: Optional[UUID] = None,
unassigned: bool = False,
ai_state: Optional[str] = None,
search: Optional[str] = None,
sort: str = "updated",
page: PageNumber = 1,
page_size: int = Query(default=25, ge=1, le=MAX_PAGE_SIZE),
current_user: User = Depends(view_tickets),
db: Session = Depends(get_db),
):
check_ticketing_access(db, current_user.organization_id)
service = _service(db)
statuses = [s.strip() for s in status_filter.split(",") if s.strip()] if status_filter else None
tickets, total = service.repo.list(
current_user.organization_id,
status=statuses,
priority=priority,
assignee_user_id=assignee_id,
unassigned=unassigned,
ai_state=ai_state,
search=search,
sort=sort,
page=page,
page_size=page_size,
)
settings = service.settings_repo.get_or_create(current_user.organization_id)
active_ids = service.repo.active_run_ticket_ids([t.id for t in tickets])
items = [_list_item(service, t, t.id in active_ids, settings) for t in tickets]
return TicketListResponse(
tickets=items, pagination=Pagination.build(total, page, page_size)
)
@router.get("/stats", response_model=TicketStats)
async def ticket_stats(
current_user: User = Depends(view_tickets),
db: Session = Depends(get_db),
):
check_ticketing_access(db, current_user.organization_id)
return TicketStats(**_service(db).stats(current_user.organization_id))
@router.get("/settings", response_model=TicketSettingsOut)
async def get_settings(
current_user: User = Depends(require_any_permission("manage_organization", "manage_tickets")),
db: Session = Depends(get_db),
):
check_ticketing_access(db, current_user.organization_id)
from app.repositories.ticket import TicketSettingsRepository
settings = TicketSettingsRepository(db).get_or_create(current_user.organization_id)
out = TicketSettingsOut.model_validate(settings)
# The alert-webhook secret is the sole credential for the intake endpoint —
# only settings managers (who can rotate it) may read it back.
if not check_permissions(current_user, ["manage_organization"]):
out.alert_webhook_secret = None
return out
@router.put("/settings", response_model=TicketSettingsOut)
async def update_settings(
payload: TicketSettingsUpdate,
current_user: User = Depends(require_any_permission("manage_organization")),
db: Session = Depends(get_db),
):
check_ticketing_access(db, current_user.organization_id)
from app.repositories.ticket import TicketSettingsRepository
settings = TicketSettingsRepository(db).get_or_create(current_user.organization_id)
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(settings, key, value.value if hasattr(value, "value") else value)
# First enable mints the intake secret (re-enabling keeps it stable).
if settings.alert_webhook_enabled and not settings.alert_webhook_secret:
import secrets as py_secrets
settings.alert_webhook_secret = py_secrets.token_urlsafe(24)
db.commit()
db.refresh(settings)
return settings
@router.get("/by-session/{session_id}", response_model=Optional[TicketOut])
async def get_ticket_by_session(
session_id: UUID,
current_user: User = Depends(view_tickets),
db: Session = Depends(get_db),
):
check_ticketing_access(db, current_user.organization_id)
service = _service(db)
ticket = service.repo.get_by_session(session_id)
if ticket is None or ticket.organization_id != current_user.organization_id:
return None
return _ticket_out(service, ticket)
@router.get("/draft-from-session/{session_id}")
async def draft_from_session(
session_id: UUID,
current_user: User = Depends(view_tickets),
db: Session = Depends(get_db),
):
"""Non-LLM prefill for the create-ticket modal: title from the customer's
last message, description from the recent transcript. AI triage rewrites
the title after creation."""
check_ticketing_access(db, current_user.organization_id)
# Cross-org guard: the transcript is customer data — the session must
# belong to the caller's organization.
session_record = SessionToAgentRepository(db).get_session(session_id)
if session_record is None or str(session_record.organization_id) != str(current_user.organization_id):
raise HTTPException(status_code=404, detail="Session not found")
service = _service(db)
messages = (await ChatRepository(db).get_session_history(session_id))[-20:]
customer_messages = [m for m in messages if m.message_type == "user"]
title = ""
if customer_messages:
title = (customer_messages[-1].message or "").strip().split("\n")[0][:200]
transcript = await service.build_session_transcript(session_id, max_messages=10, line_chars=300)
return {
"title": title or "Support issue from conversation",
"description": (transcript or "")[:5000],
}
def _validate_assignment_references(db: Session, org_id, assignee_user_id, group_id) -> None:
"""A ticket may only be assigned to a user or group in the caller's org.
Both create and update run this, so a ticket (and its assignment activity
log) can never point at a user or group from another tenant.
"""
if assignee_user_id is not None:
assignee = db.query(User).filter(User.id == assignee_user_id).first()
if assignee is None or str(assignee.organization_id) != str(org_id):
raise HTTPException(status_code=404, detail="Assignee not found")
if group_id is not None:
from app.models.user import UserGroup
group = db.query(UserGroup).filter(UserGroup.id == group_id).first()
if group is None or str(group.organization_id) != str(org_id):
raise HTTPException(status_code=404, detail="Group not found")
def _validate_create_references(db: Session, payload: TicketCreate, org_id) -> None:
"""Every referenced entity must belong to the caller's organization."""
if payload.customer_id is not None:
from app.models.customer import Customer
customer = db.query(Customer).filter(Customer.id == payload.customer_id).first()
if customer is None or str(customer.organization_id) != str(org_id):
raise HTTPException(status_code=404, detail="Customer not found")
_validate_assignment_references(
db, org_id, payload.assignee_user_id, payload.group_id)
@router.post("", response_model=TicketDetailResponse, status_code=201)
async def create_ticket(
payload: TicketCreate,
current_user: User = Depends(manage_tickets),
db: Session = Depends(get_db),
):
check_ticketing_access(db, current_user.organization_id)
_validate_create_references(db, payload, current_user.organization_id)
service = _service(db)
settings = service.settings_repo.get_or_create(current_user.organization_id)
try:
# Threadpool: create_ticket embeds the title/description with a local
# CPU-bound model — must not block the event loop.
from starlette.concurrency import run_in_threadpool
ticket, duplicates = await run_in_threadpool(
service.create_ticket,
organization_id=current_user.organization_id,
title=payload.title,
description=payload.description,
priority=payload.priority,
severity=payload.severity,
tags=payload.tags,
source=TicketSource.HUMAN_AGENT if payload.session_id else TicketSource.MANUAL,
customer_id=payload.customer_id,
customer_email=payload.customer_email,
customer_name=payload.customer_name,
session_id=payload.session_id,
assignee_user_id=payload.assignee_user_id,
group_id=payload.group_id,
created_by_user_id=current_user.id,
)
db.commit()
except ValueError as e:
db.rollback()
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
db.rollback()
logger.error(f"Ticket creation failed: {e}")
raise HTTPException(status_code=500, detail="Failed to create ticket")
db.refresh(ticket)
await service.notify_customer_created(ticket, settings)
db.commit()
await emit_ticket_update(current_user.organization_id, ticket.id, "created")
return TicketDetailResponse(
ticket=_ticket_out(service, ticket),
activities=[_activity_out(a) for a in service.activity_repo.list_for_ticket(ticket.id)],
runs=[InvestigationRunOut.model_validate(r) for r in service.run_repo.list_for_ticket(ticket.id)],
linked_session_ids=service.repo.get_session_ids(ticket.id),
possible_duplicates=[
_list_item(service, t, False, settings) for t, _score in duplicates
],
can_notify_customer=service.can_notify_customer(ticket),
)
@router.get("/{ticket_id}", response_model=TicketDetailResponse)
async def get_ticket(
ticket_id: UUID,
current_user: User = Depends(view_tickets),
db: Session = Depends(get_db),
):
check_ticketing_access(db, current_user.organization_id)
service = _service(db)
ticket = _get_ticket_or_404(db, ticket_id, current_user)
return TicketDetailResponse(
ticket=_ticket_out(service, ticket),
activities=[_activity_out(a) for a in service.activity_repo.list_for_ticket(ticket.id)],
runs=[InvestigationRunOut.model_validate(r) for r in service.run_repo.list_for_ticket(ticket.id)],
linked_session_ids=service.repo.get_session_ids(ticket.id),
can_notify_customer=service.can_notify_customer(ticket),
)
@router.patch("/{ticket_id}", response_model=TicketOut)
async def update_ticket(
ticket_id: UUID,
payload: TicketUpdate,
current_user: User = Depends(manage_tickets),
db: Session = Depends(get_db),
):
check_ticketing_access(db, current_user.organization_id)
service = _service(db)
ticket = _get_ticket_or_404(db, ticket_id, current_user)
data = payload.model_dump(exclude_unset=True)
try:
if "status" in data:
service.transition_status(
ticket, data.pop("status"),
actor_type=TicketActorType.USER, actor_user_id=current_user.id,
)
if "priority" in data:
service.set_priority(
ticket, data.pop("priority"),
actor_type=TicketActorType.USER, actor_user_id=current_user.id,
)
if "assignee_user_id" in data or "group_id" in data:
new_assignee = data.pop("assignee_user_id", ticket.assignee_user_id)
new_group = data.pop("group_id", ticket.group_id)
# Same org guard as create — the update path bypassed it entirely.
_validate_assignment_references(
db, current_user.organization_id, new_assignee, new_group)
service.assign(
ticket,
new_assignee,
new_group,
actor_user_id=current_user.id,
)
customer_email = data.pop("customer_email", None)
customer_name = data.pop("customer_name", None)
if customer_email:
service.set_customer(
ticket, customer_email, customer_name, actor_user_id=current_user.id
)
for key, value in data.items():
setattr(ticket, key, value.value if hasattr(value, "value") else value)
from app.services.ticket_jira import maybe_escalate_to_jira
await maybe_escalate_to_jira(
db, service, ticket, service.settings_repo.get_or_create(current_user.organization_id)
)
db.commit()
except ValueError as e:
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
db.rollback()
logger.error(f"Ticket update failed: {e}")
raise HTTPException(status_code=500, detail="Failed to update ticket")
db.refresh(ticket)
await emit_ticket_update(
current_user.organization_id, ticket.id, "updated",
{"status": str(ticket.status), "priority": str(ticket.priority)},
)
return _ticket_out(service, ticket)
@router.post("/{ticket_id}/comments", response_model=TicketActivityOut, status_code=201)
async def add_comment(
ticket_id: UUID,
payload: TicketCommentCreate,
current_user: User = Depends(view_tickets),
db: Session = Depends(get_db),
):
check_ticketing_access(db, current_user.organization_id)
# Customer-visible replies are a customer-facing action, not a read:
# view-only roles may add internal notes but never message customers.
if not payload.is_internal and not check_permissions(current_user, ["manage_tickets"]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Sending customer-visible replies requires manage_tickets",
)
service = _service(db)
ticket = _get_ticket_or_404(db, ticket_id, current_user)
activity = service.add_comment(
ticket,
payload.body,
is_internal=payload.is_internal,
actor_type=TicketActorType.USER,
actor_user_id=current_user.id,
)
if not payload.is_internal:
# The comment row above is the timeline entry; skip the extra
# CUSTOMER_NOTIFIED activity so the reply isn't duplicated.
await service.send_customer_message(ticket, payload.body, record_activity=False)
db.commit()
db.refresh(activity)
await emit_ticket_update(current_user.organization_id, ticket.id, "comment")
return _activity_out(activity)
@router.post("/{ticket_id}/resolve", response_model=TicketOut)
async def resolve_ticket(
ticket_id: UUID,
payload: TicketResolve,
current_user: User = Depends(manage_tickets),
db: Session = Depends(get_db),
):
check_ticketing_access(db, current_user.organization_id)
service = _service(db)
# Lock the row so a double-submitted resolve serializes: the second request
# blocks until the first commits, then sees RESOLVED_PENDING_CONFIRMATION
# and skips re-notifying the customer.
ticket = _get_ticket_or_404(db, ticket_id, current_user, for_update=True)
try:
await service.resolve(
ticket,
outcome=payload.outcome,
resolution_summary=payload.resolution_summary,
customer_message=payload.customer_message,
actor_type=TicketActorType.USER,
actor_user_id=current_user.id,
)
db.commit()
except ValueError as e:
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
db.refresh(ticket)
await emit_ticket_update(
current_user.organization_id, ticket.id, "status", {"status": str(ticket.status)}
)
return _ticket_out(service, ticket)
@router.post("/{ticket_id}/reopen", response_model=TicketOut)
async def reopen_ticket(
ticket_id: UUID,
payload: TicketReopen,
current_user: User = Depends(manage_tickets),
db: Session = Depends(get_db),
):
check_ticketing_access(db, current_user.organization_id)
service = _service(db)
ticket = _get_ticket_or_404(db, ticket_id, current_user)
if str(ticket.status) not in SETTLED_STATUSES:
raise HTTPException(status_code=400, detail="Only resolved or closed tickets can be reopened")
service.reopen(
ticket, reason=payload.reason,
actor_type=TicketActorType.USER, actor_user_id=current_user.id,
)
db.commit()
db.refresh(ticket)
await emit_ticket_update(
current_user.organization_id, ticket.id, "status", {"status": str(ticket.status)}
)
return _ticket_out(service, ticket)
@router.post("/{ticket_id}/investigate", response_model=InvestigationRunOut, status_code=201)
async def investigate_ticket(
ticket_id: UUID,
payload: Optional[InvestigateRequest] = None,
current_user: User = Depends(manage_tickets),
db: Session = Depends(get_db),
):
"""Manually (re-)enqueue an AI run — a full investigation by default."""
check_ticketing_access(db, current_user.organization_id)
service = _service(db)
ticket = _get_ticket_or_404(db, ticket_id, current_user)
if str(ticket.status) in SETTLED_STATUSES:
raise HTTPException(
status_code=409,
detail="This ticket is resolved — reopen it before running another investigation",
)
payload = payload or InvestigateRequest()
# Distinguish "out of credits" (402, actionable — upgrade) from the generic
# 409 below, for a human clicking Investigate.
if payload.run_type == InvestigationRunType.INVESTIGATION and service._investigation_over_budget(ticket):
raise HTTPException(
status_code=402,
detail=(
"Not enough message credits left in your plan to run an AI "
"investigation. Upgrade your plan or switch to your own AI model key."
),
)
run = service.enqueue_run(
ticket,
run_type=payload.run_type,
trigger=InvestigationTrigger.MANUAL,
requested_by_user_id=current_user.id,
context_note=payload.context_note,
)
if run is None:
raise HTTPException(
status_code=409,
detail="An AI run is already active for this ticket, or its run limit was reached",
)
db.commit()
db.refresh(run)
await emit_ticket_update(current_user.organization_id, ticket.id, "run", {"status": "pending"})
return InvestigationRunOut.model_validate(run)
@router.get("/{ticket_id}/investigation", response_model=InvestigationDetailResponse)
async def get_investigation(
ticket_id: UUID,
run_id: Optional[UUID] = None,
current_user: User = Depends(view_tickets),
db: Session = Depends(get_db),
):
"""Glass-box payload: the latest (or requested) investigation run with its
hypotheses, evidence events and the ticket's current RCA document."""
check_ticketing_access(db, current_user.organization_id)
service = _service(db)
ticket = _get_ticket_or_404(db, ticket_id, current_user)
run = None
if run_id is not None:
run = service.run_repo.get_by_id(run_id)
# The run must belong to this ticket — run_id alone is not an
# authorization boundary.
if run is None or run.ticket_id != ticket.id:
raise HTTPException(status_code=404, detail="Run not found")
else:
run = service.run_repo.latest_run_of_type(
ticket.id, InvestigationRunType.INVESTIGATION.value
)
rca = service.run_repo.latest_rca(ticket.id)
return InvestigationDetailResponse(
run=InvestigationRunOut.model_validate(run) if run else None,
hypotheses=[
HypothesisOut.model_validate(h) for h in service.run_repo.list_hypotheses(run.id)
] if run else [],
events=[
InvestigationEventOut.model_validate(e) for e in service.run_repo.list_events(run.id)
] if run else [],
rca=_rca_out(rca),
proposal=_proposal_out(service.latest_proposal(ticket.id)),
)
def _proposal_out(proposal) -> Optional[ProposalOut]:
if proposal is None:
return None
out = ProposalOut.model_validate(proposal)
if proposal.decided_by is not None:
out.decided_by_name = proposal.decided_by.full_name
return out
@router.post("/{ticket_id}/proposal/approve", response_model=ProposalOut)
async def approve_proposal(
ticket_id: UUID,
current_user: User = Depends(approve_actions),
db: Session = Depends(get_db),
):
"""Approve the pending AI-proposed resolution: the ticket resolves and
the customer is notified with the proposal's message."""
check_ticketing_access(db, current_user.organization_id)
service = _service(db)
ticket = _get_ticket_or_404(db, ticket_id, current_user)
proposal = service.pending_proposal(ticket.id)
if proposal is None:
raise HTTPException(status_code=404, detail="No pending proposal for this ticket")
try:
await service.approve_proposal(ticket, proposal, current_user.id)
db.commit()
except ValueError as e:
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
db.refresh(proposal)
await emit_ticket_update(
current_user.organization_id, ticket.id, "status", {"status": str(ticket.status)}
)
return _proposal_out(proposal)
@router.post("/{ticket_id}/proposal/reject", response_model=ProposalOut)
async def reject_proposal(
ticket_id: UUID,
payload: ProposalRejectRequest,
current_user: User = Depends(approve_actions),
db: Session = Depends(get_db),
):
"""Reject the pending proposal; optionally re-run the investigation with
the rejection reason as context."""
check_ticketing_access(db, current_user.organization_id)
service = _service(db)
ticket = _get_ticket_or_404(db, ticket_id, current_user)
proposal = service.pending_proposal(ticket.id)
if proposal is None:
raise HTTPException(status_code=404, detail="No pending proposal for this ticket")
try:
service.reject_proposal(
ticket, proposal, current_user.id, payload.reason,
reinvestigate=payload.reinvestigate,
)
db.commit()
except ValueError as e:
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
db.refresh(proposal)
await emit_ticket_update(
current_user.organization_id, ticket.id, "status", {"status": str(ticket.status)}
)
return _proposal_out(proposal)
def _rca_out(rca) -> Optional[RCADocumentOut]:
if rca is None:
return None
out = RCADocumentOut.model_validate(rca)
if rca.reviewed_by is not None:
out.reviewed_by_name = rca.reviewed_by.full_name
return out
@router.patch("/{ticket_id}/rca", response_model=RCADocumentOut)
async def update_rca(
ticket_id: UUID,
payload: RcaUpdate,
current_user: User = Depends(manage_tickets),
db: Session = Depends(get_db),
):
"""Edit the RCA's customer summary and/or stamp the reviewer byline."""
check_ticketing_access(db, current_user.organization_id)
service = _service(db)
ticket = _get_ticket_or_404(db, ticket_id, current_user)
rca = service.run_repo.latest_rca(ticket.id)
if rca is None:
raise HTTPException(status_code=404, detail="No RCA document for this ticket")
if payload.customer_summary is not None:
rca.customer_summary = payload.customer_summary
if payload.mark_reviewed:
rca.reviewed_by_user_id = current_user.id
rca.reviewed_at = datetime.now(timezone.utc)
db.commit()
db.refresh(rca)
return _rca_out(rca)
@router.post("/{ticket_id}/rca/send-customer", response_model=TicketActivityOut)
async def send_rca_to_customer(
ticket_id: UUID,
current_user: User = Depends(manage_tickets),
db: Session = Depends(get_db),
):
"""Send the RCA's customer summary to the customer (a manual human action
at autonomy L1/L2). Stamps the sender as reviewer."""
check_ticketing_access(db, current_user.organization_id)
service = _service(db)
ticket = _get_ticket_or_404(db, ticket_id, current_user)
rca = service.run_repo.latest_rca(ticket.id)
if rca is None or not (rca.customer_summary or "").strip():
raise HTTPException(status_code=404, detail="No customer summary to send")
if not service.can_notify_customer(ticket):
raise HTTPException(
status_code=400,
detail="No customer channel — link a conversation or set a customer email first",
)
rca.reviewed_by_user_id = current_user.id
rca.reviewed_at = datetime.now(timezone.utc)
activity = service.add_comment(
ticket,
rca.customer_summary,
is_internal=False,
actor_type=TicketActorType.USER,
actor_user_id=current_user.id,
)
await service.send_customer_message(ticket, rca.customer_summary, record_activity=False)
db.commit()
db.refresh(activity)
await emit_ticket_update(current_user.organization_id, ticket.id, "comment")
return _activity_out(activity)