""" 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, timedelta, timezone from typing import List, Optional, Tuple from uuid import UUID from sqlalchemy.orm import Session from app.channels.constants import MessageSource from app.core.logger import get_logger from app.models.investigation import ( InvestigationRun, InvestigationRunType, InvestigationTrigger, ProposalStatus, TicketProposal, ) from app.models.session_to_agent import SessionToAgent from app.models.ticket import ( OPEN_TICKET_STATUSES, TICKET_STATUS_TRANSITIONS, ResolutionOutcome, Ticket, TicketPriority, TicketSource, TicketStatus, ) from app.models.ticket_activity import TicketActivity, TicketActivityType, TicketActorType from app.models.ticket_settings import ( DEFAULT_CREATED_TEMPLATE, DEFAULT_RESOLVED_TEMPLATE, DEFAULT_SLA_TARGETS, OrganizationTicketSettings, ) from app.repositories.chat import ChatRepository from app.repositories.session_to_agent import SessionToAgentRepository from app.services.message_delivery import deliver_to_customer from app.repositories.ticket import ( InvestigationRepository, TicketActivityRepository, TicketRepository, TicketSettingsRepository, ) logger = get_logger(__name__) # Widget sessions carry this channel; their replies reach the browser tab over # Socket.IO only, so ticket updates to them are also emailed (see below). WEB_CHANNEL = "web" # Open tickets whose title+description embed this close to an existing open # ticket are flagged as possible duplicates. DUPLICATE_SIMILARITY_THRESHOLD = 0.90 # Denormalized mirror value on session_to_agents.integration_type for native # tickets (Jira sessions keep 'JIRA'). NATIVE_INTEGRATION_TYPE = "NATIVE" # Trailing window for the workspace CSAT stat. Wider than the 7-day resolution # stats because CSAT responses are sparse. CSAT_WINDOW_DAYS = 30 _embedder = None def _get_embedder(): """Lazy module-level FastEmbed embedder (local model, no API cost).""" global _embedder if _embedder is None: from app.knowledge.embedder import get_embedder _embedder = get_embedder() return _embedder def embed_ticket_text(title: str, description: Optional[str]) -> Optional[List[float]]: try: content = title if not description else f"{title}\n\n{description}" return _get_embedder().get_embedding(content[:4000]) except Exception as e: logger.error(f"Ticket embedding failed: {e}") return None def render_customer_template(template: str, customer_name: Optional[str], display_number: str) -> str: return ( template.replace("[customer]", customer_name or "there") .replace("[ticket]", display_number) ) class TicketService: """Ticket lifecycle. Every mutation writes a TicketActivity row — the activity table is the audit trail, never bypass it.""" def __init__(self, db: Session): self.db = db self.repo = TicketRepository(db) self.activity_repo = TicketActivityRepository(db) self.settings_repo = TicketSettingsRepository(db) self.run_repo = InvestigationRepository(db) # ---------- creation ---------- def create_ticket( self, organization_id: UUID, title: str, description: Optional[str] = None, priority: str = TicketPriority.MEDIUM, severity: Optional[int] = None, tags: Optional[List[str]] = None, source: str = TicketSource.MANUAL, customer_id: Optional[UUID] = None, customer_email: Optional[str] = None, customer_name: Optional[str] = None, session_id: Optional[UUID] = None, agent_id: Optional[UUID] = None, assignee_user_id: Optional[UUID] = None, group_id: Optional[UUID] = None, created_by_user_id: Optional[UUID] = None, link_session_customer: bool = True, ) -> Tuple[Ticket, List[Tuple[Ticket, float]]]: """Create a ticket, link its session, mirror the legacy session columns, and enqueue triage. Returns (ticket, possible_duplicates). Caller commits. customer_email (manual tickets): resolved to an existing customer in the org or a new record — the direct-email notification path. link_session_customer=False keeps the session's customer off the ticket: a widget visitor bound to a customer by an email they only typed must not hand the investigator that customer's identity.""" settings = self.settings_repo.get_or_create(organization_id) if customer_id is None and customer_email: customer_id = self._resolve_customer_by_email( organization_id, customer_email, customer_name ) session_record = None if session_id is not None: session_record = SessionToAgentRepository(self.db).get_session(session_id) # Cross-org guard: linking mutates the session row and messages its # customer, so a foreign session must be rejected, not ignored. if session_record is None or str(session_record.organization_id) != str(organization_id): raise ValueError("Session not found in this organization") if customer_id is None and link_session_customer: customer_id = session_record.customer_id if agent_id is None: agent_id = session_record.agent_id embedding = embed_ticket_text(title, description) ticket = Ticket( organization_id=organization_id, customer_id=customer_id, title=title, description=description, priority=priority, severity=severity, tags=tags, source=source, status=TicketStatus.OPEN, agent_id=agent_id, assignee_user_id=assignee_user_id, group_id=group_id, created_by_user_id=created_by_user_id, embedding=embedding, ) self.repo.create(ticket) possible_duplicates: List[Tuple[Ticket, float]] = [] if embedding is not None: possible_duplicates = self.repo.find_similar( organization_id, embedding, limit=3, exclude_ticket_id=ticket.id, only_open=True, min_similarity=DUPLICATE_SIMILARITY_THRESHOLD, ) actor_type = TicketActorType.AI if source == TicketSource.CHAT_AI else ( TicketActorType.USER if created_by_user_id else TicketActorType.SYSTEM ) self._add_activity( ticket, TicketActivityType.STATUS_CHANGE, actor_type=actor_type, actor_user_id=created_by_user_id, body=( f"Ticket created from conversation" if session_id else "Ticket created" ), metadata={"to": TicketStatus.OPEN.value, "source": source}, ) if session_record is not None: self.repo.link_session(ticket.id, session_record.session_id) self._mirror_session_columns(session_record, ticket) if settings.auto_investigate_on_create: self.enqueue_run( ticket, run_type=InvestigationRunType.TRIAGE, trigger=InvestigationTrigger.AUTO_ON_CREATE, settings=settings, ) return ticket, possible_duplicates def _mirror_session_columns(self, session_record: SessionToAgent, ticket: Ticket) -> None: """Keep the deprecated session_to_agents.ticket_* columns in sync so existing session/widget consumers keep working. Never clobbers an explicit Jira linkage — JiraTools reads ticket_id as a Jira issue key.""" if session_record.integration_type not in (None, NATIVE_INTEGRATION_TYPE): return try: session_record.ticket_id = ticket.display_number session_record.ticket_status = str(ticket.status) session_record.ticket_summary = ticket.title[:255] session_record.ticket_description = (ticket.description or "")[:1000] session_record.integration_type = NATIVE_INTEGRATION_TYPE session_record.ticket_priority = str(ticket.priority) self.db.flush() except Exception as e: logger.error(f"Failed to mirror ticket columns onto session: {e}") def _sync_linked_sessions(self, ticket: Ticket) -> None: session_repo = SessionToAgentRepository(self.db) for sid in self.repo.get_session_ids(ticket.id): record = session_repo.get_session(sid) if record is not None and record.integration_type == NATIVE_INTEGRATION_TYPE: record.ticket_status = str(ticket.status) record.ticket_priority = str(ticket.priority) self.db.flush() async def build_session_transcript( self, session_id, max_messages: int = 15, line_chars: int = 400 ) -> Optional[str]: """Plain-text transcript of a session's recent messages — shared by the create-modal draft endpoint and the triage context builder.""" messages = (await ChatRepository(self.db).get_session_history(session_id))[-max_messages:] lines = [] for m in messages: who = "Customer" if m.message_type == "user" else "Agent" text = (m.message or "").strip() if text: lines.append(f"{who}: {text[:line_chars]}") return "\n".join(lines) or None # ---------- lifecycle ---------- def transition_status( self, ticket: Ticket, new_status: TicketStatus, actor_type: str = TicketActorType.USER, actor_user_id: Optional[UUID] = None, reason: Optional[str] = None, ) -> Ticket: current = TicketStatus(ticket.status) new_status = TicketStatus(new_status) if new_status == current: return ticket allowed = TICKET_STATUS_TRANSITIONS.get(current, set()) if new_status not in allowed: raise ValueError(f"Illegal status transition {current.value} -> {new_status.value}") ticket.status = new_status now = datetime.now(timezone.utc) if new_status == TicketStatus.RESOLVED and ticket.resolved_at is None: ticket.resolved_at = now if ticket.resolved_by_actor is None: ticket.resolved_by_actor = "ai" if actor_type == TicketActorType.AI else "user" if new_status == TicketStatus.RESOLVED_PENDING_CONFIRMATION: ticket.confirmation_requested_at = now if new_status == TicketStatus.CLOSED: ticket.closed_at = now if ticket.resolved_at is None: ticket.resolved_at = now if ticket.resolved_by_actor is None: ticket.resolved_by_actor = "ai" if actor_type == TicketActorType.AI else "user" self._add_activity( ticket, TicketActivityType.STATUS_CHANGE, actor_type=actor_type, actor_user_id=actor_user_id, body=reason or f"Status changed {current.value} → {new_status.value}", metadata={"from": current.value, "to": new_status.value}, ) self._sync_linked_sessions(ticket) return ticket def assign( self, ticket: Ticket, assignee_user_id: Optional[UUID], group_id: Optional[UUID], actor_user_id: Optional[UUID] = None, ) -> Ticket: ticket.assignee_user_id = assignee_user_id ticket.group_id = group_id self._add_activity( ticket, TicketActivityType.ASSIGNMENT, actor_type=TicketActorType.USER if actor_user_id else TicketActorType.SYSTEM, actor_user_id=actor_user_id, metadata={ "assignee_user_id": str(assignee_user_id) if assignee_user_id else None, "group_id": str(group_id) if group_id else None, }, ) return ticket def set_priority( self, ticket: Ticket, priority: TicketPriority, actor_type: str = TicketActorType.USER, actor_user_id: Optional[UUID] = None, ) -> Ticket: old = ticket.priority if str(old) == str(priority): return ticket ticket.priority = priority self._add_activity( ticket, TicketActivityType.PRIORITY_CHANGE, actor_type=actor_type, actor_user_id=actor_user_id, metadata={"from": str(old), "to": str(priority)}, ) self._sync_linked_sessions(ticket) return ticket def add_comment( self, ticket: Ticket, body: str, is_internal: bool = True, actor_type: str = TicketActorType.USER, actor_user_id: Optional[UUID] = None, ) -> TicketActivity: activity = self._add_activity( ticket, TicketActivityType.COMMENT, actor_type=actor_type, actor_user_id=actor_user_id, body=body, is_internal=is_internal, ) if ticket.first_response_at is None and actor_type in ( TicketActorType.USER, TicketActorType.AI ) and not is_internal: ticket.first_response_at = datetime.now(timezone.utc) return activity async def resolve( self, ticket: Ticket, outcome: str, resolution_summary: Optional[str] = None, customer_message: Optional[str] = None, actor_type: str = TicketActorType.USER, actor_user_id: Optional[UUID] = None, ) -> Ticket: """Resolve pending customer confirmation. Notifies the customer with the plain-language message; the lifecycle worker closes it after the confirmation timeout.""" # A repeat resolve (double-clicked button, client retry) must not # re-send the resolution message. transition_status already no-ops when # the status is unchanged, so key the notification off a real # transition: only notify when the ticket wasn't already awaiting # confirmation. Pair with a row lock in the endpoint so concurrent # resolves serialize and the later ones observe the resolved status. already_pending = ( TicketStatus(ticket.status) == TicketStatus.RESOLVED_PENDING_CONFIRMATION ) ticket.resolution_outcome = outcome if resolution_summary: ticket.resolution_summary = resolution_summary if customer_message: ticket.customer_resolution_message = customer_message ticket.resolved_by_actor = "ai" if actor_type == TicketActorType.AI else "user" self.transition_status( ticket, TicketStatus.RESOLVED_PENDING_CONFIRMATION, actor_type=actor_type, actor_user_id=actor_user_id, ) if not already_pending: await self.notify_customer_resolved(ticket) return ticket async def close( self, ticket: Ticket, actor_type: str = TicketActorType.SYSTEM, actor_user_id: Optional[UUID] = None, reason: Optional[str] = None, ) -> Ticket: self.transition_status( ticket, TicketStatus.CLOSED, actor_type=actor_type, actor_user_id=actor_user_id, reason=reason, ) settings = self.settings_repo.get_or_create(ticket.organization_id) if settings.csat_enabled: await self.request_csat(ticket) return ticket def reopen( self, ticket: Ticket, reason: Optional[str] = None, actor_type: str = TicketActorType.USER, actor_user_id: Optional[UUID] = None, ) -> Ticket: ticket.reopened_count = (ticket.reopened_count or 0) + 1 ticket.resolved_at = None ticket.closed_at = None ticket.confirmation_requested_at = None ticket.resolved_by_actor = None ticket.status = TicketStatus.REOPENED self._add_activity( ticket, TicketActivityType.REOPENED, actor_type=actor_type, actor_user_id=actor_user_id, body=reason, ) self._sync_linked_sessions(ticket) return ticket # ---------- AI runs ---------- def _investigation_over_budget(self, ticket: Ticket) -> bool: """True when a metered (hosted-model) org has no message credits left. Fails open (never blocks) for own-key orgs, unlimited plans, self-host without the enterprise module, or on any metering error.""" from app.services.usage_metering import ( is_hosted_model, remaining_message_credits, ) if not is_hosted_model(self.db, ticket.organization_id): return False remaining = remaining_message_credits(self.db, ticket.organization_id) return remaining is not None and remaining <= 0 def enqueue_run( self, ticket: Ticket, run_type: str = InvestigationRunType.TRIAGE, trigger: str = InvestigationTrigger.MANUAL, requested_by_user_id: Optional[UUID] = None, context_note: Optional[str] = None, settings: Optional[OrganizationTicketSettings] = None, ) -> Optional[InvestigationRun]: settings = settings or self.settings_repo.get_or_create(ticket.organization_id) if run_type == InvestigationRunType.INVESTIGATION: done = self.run_repo.count_for_ticket(ticket.id, InvestigationRunType.INVESTIGATION) if done >= settings.max_runs_per_ticket: logger.info(f"Ticket {ticket.display_number}: max investigation runs reached") return None # An investigation makes many metered LLM calls, so a hosted-model # org that's out of message credits can't start one. Triage (cheap, # one call) is never gated, so tickets still get classified. if self._investigation_over_budget(ticket): self._add_activity( ticket, TicketActivityType.STATUS_CHANGE, actor_type=TicketActorType.SYSTEM, body=( "AI investigation paused — the organization is out of " "message credits for this billing period." ), metadata={"reason": "message_limit_reached"}, ) logger.info(f"Ticket {ticket.display_number}: investigation blocked, out of credits") return None run = self.run_repo.enqueue( InvestigationRun( ticket_id=ticket.id, organization_id=ticket.organization_id, run_type=run_type, trigger=trigger, requested_by_user_id=requested_by_user_id, context_note=context_note, max_tool_calls=settings.max_tool_calls_per_run, ) ) return run # ---------- autonomy L2: propose / approve ---------- def create_proposal( self, ticket: Ticket, run_id: Optional[UUID], summary: str, customer_message: Optional[str], confidence: Optional[float], ) -> TicketProposal: """Record an AI-proposed resolution and park the ticket in awaiting_approval. Any earlier pending proposal is superseded.""" ( self.db.query(TicketProposal) .filter( TicketProposal.ticket_id == ticket.id, TicketProposal.status == ProposalStatus.PENDING.value, ) .update({TicketProposal.status: ProposalStatus.SUPERSEDED.value}) ) proposal = TicketProposal( ticket_id=ticket.id, organization_id=ticket.organization_id, run_id=run_id, summary=summary, customer_message=customer_message, confidence=confidence, ) self.db.add(proposal) self.db.flush() self._add_activity( ticket, TicketActivityType.AI_RESOLUTION_PROPOSED, actor_type=TicketActorType.AI, body=summary, metadata={"proposal_id": str(proposal.id), "confidence": confidence}, ) if TicketStatus(str(ticket.status)) in TICKET_STATUS_TRANSITIONS and ( TicketStatus.AWAITING_APPROVAL in TICKET_STATUS_TRANSITIONS[TicketStatus(str(ticket.status))] ): self.transition_status( ticket, TicketStatus.AWAITING_APPROVAL, actor_type=TicketActorType.AI ) return proposal async def approve_proposal( self, ticket: Ticket, proposal: TicketProposal, user_id: UUID ) -> Ticket: """Human approval: the proposed resolution is applied — the ticket resolves (pending customer confirmation) and the customer gets the proposal's message. Any infra change stays with humans/runbooks.""" proposal.status = ProposalStatus.APPROVED proposal.decided_by_user_id = user_id proposal.decided_at = datetime.now(timezone.utc) self._add_activity( ticket, TicketActivityType.AI_RESOLUTION_APPROVED, actor_type=TicketActorType.USER, actor_user_id=user_id, body=proposal.summary, metadata={"proposal_id": str(proposal.id)}, ) # The AI resolved it (a human signed off) — counts as AI-resolved. await self.resolve( ticket, outcome=ResolutionOutcome.FIXED, resolution_summary=proposal.summary, customer_message=proposal.customer_message, actor_type=TicketActorType.AI, ) return ticket def reject_proposal( self, ticket: Ticket, proposal: TicketProposal, user_id: UUID, reason: Optional[str], reinvestigate: bool = False, ) -> Optional[InvestigationRun]: """Human rejection. Optionally feeds the reason into a refined investigation run (bounded by max_runs_per_ticket).""" proposal.status = ProposalStatus.REJECTED proposal.decided_by_user_id = user_id proposal.decided_at = datetime.now(timezone.utc) proposal.reject_reason = reason self._add_activity( ticket, TicketActivityType.AI_RESOLUTION_REJECTED, actor_type=TicketActorType.USER, actor_user_id=user_id, body=reason, metadata={"proposal_id": str(proposal.id), "reinvestigate": reinvestigate}, ) if str(ticket.status) == TicketStatus.AWAITING_APPROVAL.value: self.transition_status( ticket, TicketStatus.OPEN, actor_type=TicketActorType.USER, actor_user_id=user_id, reason="Proposal rejected", ) if not reinvestigate: return None return self.enqueue_run( ticket, run_type=InvestigationRunType.INVESTIGATION, trigger=InvestigationTrigger.REJECTION_FEEDBACK, requested_by_user_id=user_id, context_note=reason, ) def pending_proposal(self, ticket_id: UUID) -> Optional[TicketProposal]: return ( self.db.query(TicketProposal) .filter( TicketProposal.ticket_id == ticket_id, TicketProposal.status == ProposalStatus.PENDING.value, ) .order_by(TicketProposal.created_at.desc()) .first() ) def latest_proposal(self, ticket_id: UUID) -> Optional[TicketProposal]: return ( self.db.query(TicketProposal) .filter(TicketProposal.ticket_id == ticket_id) .order_by(TicketProposal.created_at.desc()) .first() ) # ---------- customer communications ---------- async def notify_customer_created( self, ticket: Ticket, settings: Optional[OrganizationTicketSettings] = None ) -> None: settings = settings or self.settings_repo.get_or_create(ticket.organization_id) template = settings.created_template or DEFAULT_CREATED_TEMPLATE message = render_customer_template( template, self._customer_name(ticket), ticket.display_number ) await self.send_customer_message(ticket, message) async def notify_customer_resolved( self, ticket: Ticket, settings: Optional[OrganizationTicketSettings] = None ) -> None: settings = settings or self.settings_repo.get_or_create(ticket.organization_id) template = settings.resolved_template or DEFAULT_RESOLVED_TEMPLATE message = render_customer_template( template, self._customer_name(ticket), ticket.display_number ) if ticket.customer_resolution_message: message = f"{message}\n\n{ticket.customer_resolution_message}" await self.send_customer_message(ticket, message) async def request_csat(self, ticket: Ticket) -> None: """Ask the customer to rate the ticket, reusing the widget's existing conversation rating prompt. Conversation-only by design: a rating row is keyed to a session (ratings.session_id is NOT NULL), so a ticket with no linked conversation — manual or email-only — has nothing to rate and no capture path short of a public tokenised rating page. Those tickets are simply never asked; csat_requested_at stays NULL and the UI shows "not requested" rather than a CSAT that will never arrive. """ session_record = self._primary_session(ticket) if session_record is None: return message = ( f"How did we do on ticket {ticket.display_number}? " "Your feedback helps us improve — you can rate this conversation." ) # The widget only shows its stars on a message that also ends the chat # (as the AI's own wrap-up does); without these the customer was asked # to rate with nothing to rate with. Stored with the same attributes so # the thread and a reloaded widget show the ask too. rating_ask = { "request_rating": True, "end_chat": True, "end_chat_reason": "ISSUE_RESOLVED", } try: stored = ChatRepository(self.db).create_message({ "message": message, "message_type": "bot", "session_id": session_record.session_id, "organization_id": ticket.organization_id, "agent_id": session_record.agent_id, "customer_id": session_record.customer_id, "attributes": {"ticket_number": ticket.display_number, **rating_ask}, }) await deliver_to_customer(self.db, session_record, { "message": message, "type": "chat_response", "session_id": str(session_record.session_id), **rating_ask, }, message_id=stored.id, source=MessageSource.SYSTEM) ticket.csat_requested_at = datetime.now(timezone.utc) self._add_activity( ticket, TicketActivityType.CSAT_REQUESTED, actor_type=TicketActorType.SYSTEM, metadata={"session_id": str(session_record.session_id)}, ) except Exception as e: logger.error(f"CSAT request failed for {ticket.display_number}: {e}") def record_csat( self, ticket: Ticket, rating_id: UUID, score: int, feedback: Optional[str] = None ) -> Ticket: """Attach a submitted conversation rating to the ticket. The most recent rating wins if a customer rates the same conversation twice. Re-submitting the same score is ignored: the customer controls how often this fires, and every write here appends to the audit feed.""" if ticket.csat_rating_id == rating_id and ticket.csat_score == score: return ticket ticket.csat_score = score ticket.csat_rating_id = rating_id ticket.csat_responded_at = datetime.now(timezone.utc) body = f"Customer rated {score}/5" if feedback: body = f"{body} — {feedback}" self._add_activity( ticket, TicketActivityType.CSAT_RECEIVED, actor_type=TicketActorType.CUSTOMER, body=body, metadata={"rating_id": str(rating_id), "score": score}, ) return ticket def _resolve_customer_by_email( self, organization_id: UUID, customer_email: str, customer_name: Optional[str] ) -> UUID: from app.repositories.customer import CustomerRepository customer = CustomerRepository(self.db).get_or_create_customer( email=customer_email.strip().lower(), organization_id=organization_id, full_name=(customer_name or "").strip() or None, ) return customer.id def set_customer( self, ticket: Ticket, customer_email: str, customer_name: Optional[str] = None, actor_user_id: Optional[UUID] = None, ) -> Ticket: """Set/replace the ticket's customer by email (find-or-create).""" customer_id = self._resolve_customer_by_email( ticket.organization_id, customer_email, customer_name ) if ticket.customer_id == customer_id: return ticket ticket.customer_id = customer_id self._add_activity( ticket, TicketActivityType.CUSTOMER_LINKED, actor_type=TicketActorType.USER if actor_user_id else TicketActorType.SYSTEM, actor_user_id=actor_user_id, body=f"Customer set to {customer_email.strip().lower()}", metadata={"customer_id": str(customer_id)}, ) return ticket def can_notify_customer(self, ticket: Ticket) -> bool: """Whether any outbound path to the customer exists: a linked conversation, or a customer with an email for direct sending.""" if self.repo.get_session_ids(ticket.id): return True return bool(ticket.customer and ticket.customer.email) def _scrub_for_customer(self, ticket: Ticket, message: str) -> str: """Redact identifiers that aren't the recipient's own before delivery.""" from app.services.ticket_privacy import scrub_outbound customer = ticket.customer owned = (customer.email, customer.phone) if customer else () scrubbed = scrub_outbound(message, owned) if scrubbed != message: logger.warning( f"Third-party identifiers redacted from an outbound message on " f"{ticket.display_number}" ) return scrubbed or message async def send_customer_message( self, ticket: Ticket, message: str, record_activity: bool = True ) -> None: """Deliver a ticket message to the customer: through the linked conversation's channel (widget socket or email/WhatsApp/... adapter) when one exists, by direct email otherwise (manual tickets). record_activity=False when the caller already wrote its own activity row (customer-visible comments). Every outbound path passes through here, so this is where third-party identifiers are stripped: the investigator reads other customers' tickets and unscoped database rows, and at autonomy L3 its summary reaches the customer with no human in between.""" message = self._scrub_for_customer(ticket, message) session_record = self._primary_session(ticket) if session_record is None: await self._send_direct_email(ticket, message, record_activity) return channel = getattr(session_record, "channel", None) or WEB_CHANNEL chat_repo = ChatRepository(self.db) stored = delivery = None try: # Stored first so the channel's message id lands on this row stored = chat_repo.create_message({ "message": message, "message_type": "bot", "session_id": session_record.session_id, "organization_id": ticket.organization_id, "agent_id": session_record.agent_id, "customer_id": session_record.customer_id, "attributes": {"ticket_number": ticket.display_number}, }) delivery = await deliver_to_customer(self.db, session_record, { "message": message, "type": "chat_response", "session_id": str(session_record.session_id), }, message_id=stored.id, source=MessageSource.SYSTEM) if not delivery.ok: chat_repo.mark_delivery_failed(stored.id, delivery.reason) else: if ticket.first_response_at is None: ticket.first_response_at = datetime.now(timezone.utc) if record_activity: self._add_activity( ticket, TicketActivityType.CUSTOMER_NOTIFIED, actor_type=TicketActorType.SYSTEM, body=message, metadata={ "session_id": str(session_record.session_id), "channel": channel, }, ) except Exception as e: logger.error(f"Customer notification failed for {ticket.display_number}: {e}") # Stored but the hand-over itself raised: don't let the thread # show it as sent if stored is not None and delivery is None: chat_repo.mark_delivery_failed(stored.id, "send_failed") # Widget (web) sessions are delivered over Socket.IO to the browser tab # only, but a ticket reply almost always arrives after the visitor has # closed the chat — so it reaches nobody. When we have a real inbox for # them, also email the reply through the org's connected email channel # (their own domain) so async ticket updates actually land. External # channels (WhatsApp/Telegram/email) already deliver asynchronously via # their own adapter, so this supplement is web-only. if channel == WEB_CHANNEL: to_email = self._deliverable_email(ticket) if to_email: await self._email_customer_copy(ticket, message, to_email) async def _send_direct_email( self, ticket: Ticket, message: str, record_activity: bool ) -> None: """Email fallback for tickets with no linked conversation.""" to_email = self._deliverable_email(ticket) if not to_email: logger.info( f"Ticket {ticket.display_number}: no linked session or mailable customer email to notify" ) return if not await self._email_customer_copy(ticket, message, to_email): return if ticket.first_response_at is None: ticket.first_response_at = datetime.now(timezone.utc) if not record_activity: return self._add_activity( ticket, TicketActivityType.CUSTOMER_NOTIFIED, actor_type=TicketActorType.SYSTEM, body=message, metadata={"channel": "email", "to": to_email}, ) def _deliverable_email(self, ticket: Ticket) -> Optional[str]: """The customer's real, mailable address, or None for anonymous / placeholder records: …@noemail.com widget visitors, …@.channel identity keys, and generate-token …@.local anonymous stand-ins — none of which can receive mail.""" from app.repositories.customer import CustomerRepository email = ticket.customer.email if ticket.customer else None if not email or CustomerRepository.is_placeholder_email(email) or email.endswith(".local"): return None return email async def _email_customer_copy(self, ticket: Ticket, message: str, to_email: str) -> bool: """Email a ticket reply to a customer through the org's email channel. The single send point for both the widget (web) supplement and the no-session fallback. Returns whether the mail was sent. Best-effort — send_ticket_email swallows SMTP errors and returns False, so a mail hiccup never fails the surrounding ticket mutation.""" from app.services.ticket_email import send_ticket_email return await send_ticket_email( self.db, ticket.organization_id, to_email, subject=f"[{ticket.display_number}] {ticket.title}", body=message, ticket_id=ticket.id, in_reply_to=self.last_customer_email_message_id(ticket), ) def last_customer_email_message_id(self, ticket: Ticket) -> Optional[str]: """Message-ID of the customer's most recent emailed reply, so the next notification threads under it in their client.""" for activity in reversed(self.activity_repo.list_for_ticket(ticket.id)): if activity.activity_type != TicketActivityType.CUSTOMER_REPLIED.value: continue message_id = (activity.activity_metadata or {}).get("email_message_id") if message_id: return message_id return None def record_customer_email_reply( self, ticket: Ticket, body: str, from_email: str, message_id: Optional[str] = None ) -> TicketActivity: """Append a customer's emailed reply to the ticket feed, reopening it when the reply lands on a resolution the customer never confirmed. Customer-visible (is_internal=False): it is the customer's own words, and the agent-facing feed is where the team reads them.""" activity = self._add_activity( ticket, TicketActivityType.CUSTOMER_REPLIED, actor_type=TicketActorType.CUSTOMER, body=body, is_internal=False, metadata={ "channel": "email", "from": from_email, "email_message_id": message_id, }, ) current = TicketStatus(str(ticket.status)) if current == TicketStatus.RESOLVED_PENDING_CONFIRMATION and ( TicketStatus.REOPENED in TICKET_STATUS_TRANSITIONS.get(current, set()) ): self.reopen( ticket, reason="Customer replied by email before confirming the resolution", actor_type=TicketActorType.CUSTOMER, ) return activity def _primary_session(self, ticket: Ticket) -> Optional[SessionToAgent]: session_ids = self.repo.get_session_ids(ticket.id) if not session_ids: return None return SessionToAgentRepository(self.db).get_session(session_ids[0]) def _customer_name(self, ticket: Ticket) -> Optional[str]: try: return ticket.customer.full_name if ticket.customer else None except Exception: return None # ---------- derived fields ---------- def ai_state(self, ticket: Ticket, has_active_run: Optional[bool] = None) -> str: """Derived UI field: investigating / awaiting / human / resolved. Resolution wins over a stale not-yet-processed run.""" status = str(ticket.status) resolved_states = ( TicketStatus.RESOLVED, TicketStatus.CLOSED, TicketStatus.RESOLVED_PENDING_CONFIRMATION, ) if status in resolved_states: return "resolved" if ticket.resolved_by_actor == "ai" else "human" if has_active_run is None: has_active_run = self.run_repo.get_active_for_ticket(ticket.id) is not None if status in (TicketStatus.TRIAGING, TicketStatus.INVESTIGATING) or has_active_run: return "investigating" if status == TicketStatus.AWAITING_APPROVAL: return "awaiting" return "human" @staticmethod def _resolution_due( priority: str, created_at, settings: OrganizationTicketSettings ) -> Optional[datetime]: targets = settings.sla_targets or DEFAULT_SLA_TARGETS target = targets.get(str(priority)) if not target or not created_at: return None if created_at.tzinfo is None: created_at = created_at.replace(tzinfo=timezone.utc) return created_at + timedelta(minutes=target["resolution_minutes"]) def sla_due_at( self, ticket: Ticket, settings: Optional[OrganizationTicketSettings] = None ) -> Optional[datetime]: """Resolution SLA deadline from the org's per-priority targets.""" if str(ticket.status) not in OPEN_TICKET_STATUSES: return None settings = settings or self.settings_repo.get_or_create(ticket.organization_id) return self._resolution_due(ticket.priority, ticket.created_at, settings) def stats(self, organization_id: UUID) -> dict: counts = self.repo.count_by_status(organization_id) open_count = sum(counts.get(s, 0) for s in OPEN_TICKET_STATUSES) awaiting = counts.get(TicketStatus.AWAITING_APPROVAL.value, 0) settings = self.settings_repo.get_or_create(organization_id) now = datetime.now(timezone.utc) breaching = 0 for priority, created_at in self.repo.open_ticket_sla_fields(organization_id): due = self._resolution_due(priority, created_at, settings) if due is not None and due <= now + timedelta(minutes=30): breaching += 1 ai_resolved, total_resolved = self.repo.resolved_counts_7d(organization_id) pct = round(100.0 * ai_resolved / total_resolved, 1) if total_resolved else None return { "open": open_count, "awaiting_approval": awaiting, "sla_breaching": breaching, "ai_resolved_pct_7d": pct, **self.csat_stats(organization_id), } def csat_stats(self, organization_id: UUID, days: int = CSAT_WINDOW_DAYS) -> dict: """Overall CSAT plus the AI-resolved vs human-resolved split. A wider window than the resolution stats — CSAT responses are far sparser, and a 7-day average would be noise for most orgs.""" by_resolver = self.repo.csat_by_resolver(organization_id, days=days) total = sum(count for count, _ in by_resolver.values()) weighted = sum(count * avg for count, avg in by_resolver.values()) def side(actor: str) -> Optional[float]: entry = by_resolver.get(actor) return round(entry[1], 2) if entry else None return { "csat_responses": total, "csat_avg": round(weighted / total, 2) if total else None, "csat_ai_avg": side("ai"), "csat_human_avg": side("user"), "csat_window_days": days, } # ---------- internals ---------- def _add_activity( self, ticket: Ticket, activity_type: str, actor_type: str = TicketActorType.SYSTEM, actor_user_id: Optional[UUID] = None, body: Optional[str] = None, is_internal: bool = True, metadata: Optional[dict] = None, ) -> TicketActivity: return self.activity_repo.add( TicketActivity( ticket_id=ticket.id, activity_type=activity_type, actor_type=actor_type, actor_user_id=actor_user_id, body=body, is_internal=is_internal, activity_metadata=metadata, ) )