human hand off bug
This commit is contained in:
parent
223f846782
commit
6e7a0b9539
22 changed files with 1040 additions and 336 deletions
|
|
@ -1,3 +1,5 @@
|
|||
from datetime import timezone
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from pymongo import AsyncMongoClient
|
||||
|
|
@ -14,7 +16,11 @@ settings = get_settings()
|
|||
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
|
||||
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
mongo_client: AsyncMongoClient = AsyncMongoClient(settings.mongo_url)
|
||||
mongo_client: AsyncMongoClient = AsyncMongoClient(
|
||||
settings.mongo_url,
|
||||
tz_aware=True,
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
mongo_db = mongo_client[settings.mongo_database]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -72,10 +72,10 @@ async def list_handoff_rules(
|
|||
|
||||
def _handoff_status_label(value: str) -> str:
|
||||
return {
|
||||
"ACTIVE": "Active",
|
||||
"ACTIVE": "Open",
|
||||
"WAITING_FOR_HUMAN": "Waiting for human",
|
||||
"HUMAN_REPLIED": "Human replied",
|
||||
"CLOSED": "Closed",
|
||||
"CLOSED": "Resolved",
|
||||
}.get(value, value.replace("_", " ").title())
|
||||
|
||||
|
||||
|
|
@ -125,6 +125,7 @@ async def list_handoff_conversations(
|
|||
external_subject=handoff.get("external_subject", ""),
|
||||
external_sender=handoff.get("external_sender", ""),
|
||||
human_email=handoff.get("human_email", ""),
|
||||
human_name=handoff.get("human_name", ""),
|
||||
status=handoff.get("status", "ACTIVE"),
|
||||
status_label=_handoff_status_label(
|
||||
handoff.get("status", "ACTIVE")
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ from app.schemas import (
|
|||
MessageReadState,
|
||||
MessageStarState,
|
||||
MessageSummary,
|
||||
parse_api_datetime,
|
||||
utc_iso,
|
||||
utc_now,
|
||||
)
|
||||
from app.services.domain_provisioning import get_domain_by_name
|
||||
|
|
@ -72,7 +74,7 @@ def address(value: str, name: str | None = None) -> dict[str, str]:
|
|||
|
||||
def encode_cursor(entry: dict) -> str:
|
||||
value = json.dumps(
|
||||
{"created_at": entry["created_at"].isoformat(), "id": str(entry["_id"])},
|
||||
{"created_at": utc_iso(entry["created_at"]), "id": str(entry["_id"])},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return base64.urlsafe_b64encode(value.encode()).decode().rstrip("=")
|
||||
|
|
@ -82,7 +84,7 @@ def decode_cursor(value: str) -> tuple[datetime, ObjectId]:
|
|||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode())
|
||||
return datetime.fromisoformat(payload["created_at"]), ObjectId(payload["id"])
|
||||
return parse_api_datetime(payload["created_at"]), ObjectId(payload["id"])
|
||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from app.database import get_mongo_database
|
|||
from app.models import Account
|
||||
from app.routers.auth import get_current_account
|
||||
from app.routers.emails import decode_cursor, encode_cursor, summarize
|
||||
from app.schemas import MessageRead, ThreadPage, ThreadRead, ThreadSummary
|
||||
from app.schemas import MessageRead, ThreadPage, ThreadRead, ThreadSummary, parse_api_datetime, utc_iso
|
||||
from app.services.mailbox_scope import apply_external_scope
|
||||
from app.services.mime_parser import parse_mime
|
||||
from app.services.reply_extractor import reply_fields_for_message
|
||||
|
|
@ -23,7 +23,7 @@ router = APIRouter(prefix="/threads", tags=["threads"])
|
|||
def encode_thread_cursor(thread: dict) -> str:
|
||||
value = json.dumps(
|
||||
{
|
||||
"latest_at": thread["latest_at"].isoformat(),
|
||||
"latest_at": utc_iso(thread["latest_at"]),
|
||||
"id": str(thread["_id"]),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
|
|
@ -35,7 +35,7 @@ def decode_thread_cursor(value: str) -> tuple[datetime, ObjectId]:
|
|||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode())
|
||||
return datetime.fromisoformat(payload["latest_at"]), ObjectId(payload["id"])
|
||||
return parse_api_datetime(payload["latest_at"]), ObjectId(payload["id"])
|
||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
|
|
@ -188,9 +188,10 @@ async def list_threads(
|
|||
}
|
||||
)
|
||||
pipeline.append({"$sort": {"latest_at": -1, "_id": -1}})
|
||||
page_stages: list[dict] = []
|
||||
if cursor:
|
||||
latest_at, thread_id = decode_thread_cursor(cursor)
|
||||
pipeline.append(
|
||||
page_stages.append(
|
||||
{
|
||||
"$match": {
|
||||
"$or": [
|
||||
|
|
@ -200,8 +201,8 @@ async def list_threads(
|
|||
}
|
||||
}
|
||||
)
|
||||
pipeline.append({"$limit": limit + 1})
|
||||
pipeline.append(
|
||||
page_stages.append({"$limit": limit + 1})
|
||||
page_stages.append(
|
||||
{
|
||||
"$addFields": {
|
||||
"latest_entry": {
|
||||
|
|
@ -215,8 +216,19 @@ async def list_threads(
|
|||
}
|
||||
}
|
||||
)
|
||||
cursor = await database.mailbox_entries.aggregate(pipeline)
|
||||
rows = [row async for row in cursor]
|
||||
pipeline.append(
|
||||
{
|
||||
"$facet": {
|
||||
"total": [{"$count": "count"}],
|
||||
"page": page_stages,
|
||||
}
|
||||
}
|
||||
)
|
||||
aggregate = await database.mailbox_entries.aggregate(pipeline)
|
||||
facets = [row async for row in aggregate]
|
||||
facet = facets[0] if facets else {"total": [], "page": []}
|
||||
total = int((facet.get("total") or [{}])[0].get("count") or 0)
|
||||
rows = list(facet.get("page") or [])
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
items = [thread_summary_from_group(row, mailbox) for row in rows]
|
||||
|
|
@ -225,7 +237,7 @@ async def list_threads(
|
|||
next_cursor = encode_thread_cursor(
|
||||
{"latest_at": rows[-1]["latest_at"], "_id": rows[-1]["_id"]}
|
||||
)
|
||||
return ThreadPage(items=items, next_cursor=next_cursor)
|
||||
return ThreadPage(items=items, next_cursor=next_cursor, total=total)
|
||||
|
||||
|
||||
async def message_read(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from datetime import datetime, timezone
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
|
|
@ -313,6 +312,7 @@ class HandoffConversationSummary(BaseModel):
|
|||
external_subject: str
|
||||
external_sender: str
|
||||
human_email: str
|
||||
human_name: str = ""
|
||||
status: str
|
||||
status_label: str
|
||||
current_owner: str
|
||||
|
|
@ -488,6 +488,7 @@ class ThreadSummary(BaseModel):
|
|||
class ThreadPage(BaseModel):
|
||||
items: list[ThreadSummary]
|
||||
next_cursor: str | None = None
|
||||
total: int = 0
|
||||
|
||||
|
||||
class ThreadRead(BaseModel):
|
||||
|
|
@ -591,3 +592,18 @@ class DnsSetupResponse(BaseModel):
|
|||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def as_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def utc_iso(value: datetime) -> str:
|
||||
return as_utc(value).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_api_datetime(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return as_utc(parsed)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue