fix client chat

This commit is contained in:
TronoSfera 2026-02-27 20:43:57 +03:00
parent 69055921cd
commit 7b509f6af3
19 changed files with 3371 additions and 994 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@ -17,15 +18,53 @@ from app.models.request_data_template_item import RequestDataTemplateItem
from app.models.topic_data_template import TopicDataTemplate from app.models.topic_data_template import TopicDataTemplate
from app.services.chat_service import ( from app.services.chat_service import (
create_admin_or_lawyer_message, create_admin_or_lawyer_message,
get_chat_activity_summary,
list_messages_for_request, list_messages_for_request,
serialize_message, serialize_message,
serialize_messages_for_request, serialize_messages_for_request,
) )
from app.services.chat_presence import list_typing_presence, set_typing_presence
router = APIRouter() router = APIRouter()
ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"} ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"}
def _parse_cursor(raw: str | None) -> datetime | None:
value = str(raw or "").strip()
if not value:
return None
normalized = value.replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(normalized)
except ValueError:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _iso_or_none(dt: datetime | None) -> str | None:
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
return dt.isoformat()
def _as_utc_datetime(value) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
if isinstance(value, str):
return _parse_cursor(value)
return None
def _request_uuid_or_400(request_id: str) -> UUID: def _request_uuid_or_400(request_id: str) -> UUID:
try: try:
return UUID(str(request_id)) return UUID(str(request_id))
@ -224,6 +263,62 @@ def create_request_message(
return serialize_message(row) return serialize_message(row)
@router.get("/requests/{request_id}/live")
def get_request_live_state(
request_id: str,
cursor: str | None = None,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
):
req = _request_for_id_or_404(db, request_id)
_ensure_lawyer_can_view_request_or_403(admin, req)
summary = get_chat_activity_summary(db, req.id)
latest_activity_at = _as_utc_datetime(summary.get("latest_activity_at"))
latest_activity_iso = _iso_or_none(latest_activity_at)
cursor_dt = _parse_cursor(cursor)
has_updates = bool(latest_activity_at and (cursor_dt is None or latest_activity_at > cursor_dt))
actor_sub = str(admin.get("sub") or "").strip() or "unknown"
actor_role = str(admin.get("role") or "").strip().upper() or "UNKNOWN"
actor_key = f"{actor_role}:{actor_sub}"
typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key)
return {
"request_id": str(req.id),
"cursor": latest_activity_iso,
"has_updates": has_updates,
"message_count": int(summary.get("message_count") or 0),
"attachment_count": int(summary.get("attachment_count") or 0),
"latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))),
"latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))),
"typing": typing_rows,
}
@router.post("/requests/{request_id}/typing")
def set_request_typing_state(
request_id: str,
payload: dict,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
):
req = _request_for_id_or_404(db, request_id)
_ensure_lawyer_can_manage_request_or_403(admin, req)
actor_role = str(admin.get("role") or "").strip().upper() or "UNKNOWN"
actor_sub = str(admin.get("sub") or "").strip() or "unknown"
actor_email = str(admin.get("email") or "").strip()
actor_key = f"{actor_role}:{actor_sub}"
actor_label = actor_email or ("Юрист" if actor_role == "LAWYER" else "Администратор")
typing = bool((payload or {}).get("typing"))
set_typing_presence(
request_key=str(req.id),
actor_key=actor_key,
actor_label=actor_label,
actor_role=actor_role,
typing=typing,
)
return {"status": "ok", "typing": typing}
@router.get("/requests/{request_id}/data-request-templates") @router.get("/requests/{request_id}/data-request-templates")
def list_data_request_templates( def list_data_request_templates(
request_id: str, request_id: str,

View file

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@ -11,12 +12,55 @@ from app.models.message import Message
from app.models.request import Request from app.models.request import Request
from app.models.request_data_requirement import RequestDataRequirement from app.models.request_data_requirement import RequestDataRequirement
from app.schemas.public import PublicMessageCreate from app.schemas.public import PublicMessageCreate
from app.services.chat_service import create_client_message, list_messages_for_request, serialize_message, serialize_messages_for_request from app.services.chat_presence import list_typing_presence, set_typing_presence
from app.services.chat_service import (
create_client_message,
get_chat_activity_summary,
list_messages_for_request,
serialize_message,
serialize_messages_for_request,
)
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_lawyer from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_lawyer
router = APIRouter() router = APIRouter()
def _parse_cursor(raw: str | None) -> datetime | None:
value = str(raw or "").strip()
if not value:
return None
normalized = value.replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(normalized)
except ValueError:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _iso_or_none(dt: datetime | None) -> str | None:
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
return dt.isoformat()
def _as_utc_datetime(value) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
if isinstance(value, str):
return _parse_cursor(value)
return None
def _attachment_meta_for_public(req: Request, value_text: str | None, db: Session) -> dict | None: def _attachment_meta_for_public(req: Request, value_text: str | None, db: Session) -> dict | None:
raw = str(value_text or "").strip() raw = str(value_text or "").strip()
if not raw: if not raw:
@ -101,6 +145,58 @@ def create_message_by_track(
return serialize_message(row) return serialize_message(row)
@router.get("/requests/{track_number}/live")
def get_live_chat_state_by_track(
track_number: str,
cursor: str | None = None,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, track_number)
_ensure_view_access_or_403(session, req)
summary = get_chat_activity_summary(db, req.id)
latest_activity_at = _as_utc_datetime(summary.get("latest_activity_at"))
latest_activity_iso = _iso_or_none(latest_activity_at)
cursor_dt = _parse_cursor(cursor)
has_updates = bool(latest_activity_at and (cursor_dt is None or latest_activity_at > cursor_dt))
subject = _require_view_session_or_403(session)
actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}"
typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key)
return {
"track_number": req.track_number,
"cursor": latest_activity_iso,
"has_updates": has_updates,
"message_count": int(summary.get("message_count") or 0),
"attachment_count": int(summary.get("attachment_count") or 0),
"latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))),
"latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))),
"typing": typing_rows,
}
@router.post("/requests/{track_number}/typing")
def set_live_chat_typing_by_track(
track_number: str,
payload: dict,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, track_number)
_ensure_view_access_or_403(session, req)
subject = _require_view_session_or_403(session)
typing = bool((payload or {}).get("typing"))
actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}"
set_typing_presence(
request_key=str(req.id),
actor_key=actor_key,
actor_label=str(req.client_name or "Клиент"),
actor_role="CLIENT",
typing=typing,
)
return {"status": "ok", "typing": typing}
@router.get("/requests/{track_number}/data-requests/{message_id}") @router.get("/requests/{track_number}/data-requests/{message_id}")
def get_data_request_by_message( def get_data_request_by_message(
track_number: str, track_number: str,

View file

@ -0,0 +1,195 @@
from __future__ import annotations
import json
import threading
from datetime import datetime, timezone
from typing import Any
import redis
from app.core.config import settings
_DEFAULT_TYPING_TTL_SECONDS = 9
_redis_client: redis.Redis | None = None
_redis_lock = threading.Lock()
_memory_lock = threading.Lock()
_memory_state: dict[str, dict[str, dict[str, Any]]] = {}
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _iso_utc(dt: datetime) -> str:
return dt.astimezone(timezone.utc).isoformat()
def _get_redis_client() -> redis.Redis | None:
global _redis_client
if _redis_client is not None:
return _redis_client
with _redis_lock:
if _redis_client is not None:
return _redis_client
try:
client = redis.Redis.from_url(
settings.REDIS_URL,
decode_responses=True,
socket_timeout=0.25,
socket_connect_timeout=0.25,
)
client.ping()
_redis_client = client
return _redis_client
except Exception:
_redis_client = None
return None
def _request_actors_key(request_key: str) -> str:
return f"chat:typing:req:{request_key}:actors"
def _actor_payload_key(request_key: str, actor_key: str) -> str:
return f"chat:typing:req:{request_key}:actor:{actor_key}"
def set_typing_presence(
*,
request_key: str,
actor_key: str,
actor_label: str,
actor_role: str,
typing: bool,
ttl_seconds: int = _DEFAULT_TYPING_TTL_SECONDS,
) -> None:
normalized_request = str(request_key or "").strip()
normalized_actor = str(actor_key or "").strip()
if not normalized_request or not normalized_actor:
return
ttl = max(2, int(ttl_seconds or _DEFAULT_TYPING_TTL_SECONDS))
if typing:
payload = {
"actor_key": normalized_actor,
"actor_label": str(actor_label or "").strip() or "Собеседник",
"actor_role": str(actor_role or "").strip().upper() or "UNKNOWN",
"updated_at": _iso_utc(_utc_now()),
}
else:
payload = None
client = _get_redis_client()
if client is not None:
actors_key = _request_actors_key(normalized_request)
actor_payload_key = _actor_payload_key(normalized_request, normalized_actor)
try:
pipe = client.pipeline()
if payload is None:
pipe.delete(actor_payload_key)
pipe.srem(actors_key, normalized_actor)
else:
pipe.sadd(actors_key, normalized_actor)
pipe.setex(actor_payload_key, ttl, json.dumps(payload, ensure_ascii=False))
pipe.expire(actors_key, max(60, ttl * 8))
pipe.execute()
return
except Exception:
pass
with _memory_lock:
actors = _memory_state.setdefault(normalized_request, {})
if payload is None:
actors.pop(normalized_actor, None)
if not actors:
_memory_state.pop(normalized_request, None)
return
expires_at = _utc_now().timestamp() + ttl
actors[normalized_actor] = {**payload, "expires_at": expires_at}
def list_typing_presence(
*,
request_key: str,
exclude_actor_key: str | None = None,
) -> list[dict[str, Any]]:
normalized_request = str(request_key or "").strip()
if not normalized_request:
return []
excluded = str(exclude_actor_key or "").strip()
now_ts = _utc_now().timestamp()
client = _get_redis_client()
if client is not None:
actors_key = _request_actors_key(normalized_request)
try:
members = list(client.smembers(actors_key) or [])
if not members:
return []
keys = [_actor_payload_key(normalized_request, str(member)) for member in members]
rows = client.mget(keys)
stale_members: list[str] = []
result: list[dict[str, Any]] = []
for actor, raw in zip(members, rows):
actor_str = str(actor)
if not raw:
stale_members.append(actor_str)
continue
try:
payload = json.loads(str(raw))
except Exception:
stale_members.append(actor_str)
continue
if excluded and actor_str == excluded:
continue
result.append(
{
"actor_key": actor_str,
"actor_label": str(payload.get("actor_label") or "Собеседник"),
"actor_role": str(payload.get("actor_role") or "UNKNOWN"),
"updated_at": str(payload.get("updated_at") or ""),
}
)
if stale_members:
try:
client.srem(actors_key, *stale_members)
except Exception:
pass
result.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
return result
except Exception:
pass
with _memory_lock:
actors = _memory_state.get(normalized_request) or {}
stale: list[str] = []
result: list[dict[str, Any]] = []
for actor_key, payload in actors.items():
expires_at = float(payload.get("expires_at") or 0)
if expires_at <= now_ts:
stale.append(actor_key)
continue
if excluded and actor_key == excluded:
continue
result.append(
{
"actor_key": actor_key,
"actor_label": str(payload.get("actor_label") or "Собеседник"),
"actor_role": str(payload.get("actor_role") or "UNKNOWN"),
"updated_at": str(payload.get("updated_at") or ""),
}
)
for actor_key in stale:
actors.pop(actor_key, None)
if not actors:
_memory_state.pop(normalized_request, None)
result.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
return result
def clear_presence_for_tests() -> None:
with _memory_lock:
_memory_state.clear()

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.message import Message from app.models.message import Message
@ -212,3 +213,31 @@ def create_admin_or_lawyer_message(
db.commit() db.commit()
db.refresh(row) db.refresh(row)
return row return row
def get_chat_activity_summary(db: Session, request_id: Any) -> dict[str, Any]:
message_count, latest_message_at = (
db.query(
func.count(Message.id),
func.max(func.coalesce(Message.updated_at, Message.created_at)),
)
.filter(Message.request_id == request_id)
.one()
)
attachment_count, latest_attachment_at = (
db.query(
func.count(Attachment.id),
func.max(func.coalesce(Attachment.updated_at, Attachment.created_at)),
)
.filter(Attachment.request_id == request_id)
.one()
)
latest_candidates = [value for value in (latest_message_at, latest_attachment_at) if value is not None]
latest_activity_at = max(latest_candidates) if latest_candidates else None
return {
"message_count": int(message_count or 0),
"attachment_count": int(attachment_count or 0),
"latest_message_at": latest_message_at,
"latest_attachment_at": latest_attachment_at,
"latest_activity_at": latest_activity_at,
}

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import importlib.util import importlib.util
import logging
from typing import Any from typing import Any
from app.core.config import settings from app.core.config import settings
@ -11,6 +12,9 @@ class SmsDeliveryError(Exception):
pass pass
logger = logging.getLogger("uvicorn.error")
def _otp_dev_mode_enabled() -> bool: def _otp_dev_mode_enabled() -> bool:
return bool(getattr(settings, "OTP_DEV_MODE", False)) return bool(getattr(settings, "OTP_DEV_MODE", False))
@ -39,7 +43,8 @@ def _build_otp_message(*, code: str, purpose: str, track_number: str | None) ->
def _mock_sms_send(*, phone: str, code: str, purpose: str, track_number: str | None) -> dict[str, Any]: def _mock_sms_send(*, phone: str, code: str, purpose: str, track_number: str | None) -> dict[str, Any]:
print(f"[OTP MOCK] purpose={purpose} phone={phone} track={track_number or '-'} code={code}") line = f"[OTP MOCK] purpose={purpose} phone={phone} track={track_number or '-'} code={code}"
logger.warning(line)
return { return {
"provider": "mock_sms", "provider": "mock_sms",
"status": "accepted", "status": "accepted",
@ -203,6 +208,7 @@ def send_otp_message(*, phone: str, code: str, purpose: str, track_number: str |
if _otp_dev_mode_enabled(): if _otp_dev_mode_enabled():
payload = _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number) payload = _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number)
payload["dev_mode"] = True payload["dev_mode"] = True
payload["debug_code"] = str(code)
return payload return payload
provider = str(settings.SMS_PROVIDER or "dummy").strip().lower() provider = str(settings.SMS_PROVIDER or "dummy").strip().lower()

View file

@ -1911,6 +1911,47 @@
padding-bottom: 0.02rem; padding-bottom: 0.02rem;
} }
.request-data-progress-line {
display: flex;
justify-content: flex-end;
margin-top: -0.08rem;
}
.request-data-progress-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(105, 179, 132, 0.42);
background: rgba(57, 138, 88, 0.18);
color: #d5f5df;
padding: 0.16rem 0.56rem;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.015em;
}
.request-data-row-client-value {
grid-column: 2 / -1;
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.05rem;
min-height: 1.6rem;
}
.request-data-row-client-label {
font-size: 0.73rem;
color: #94a9c4;
white-space: nowrap;
}
.request-data-row-client-text {
color: #dbe8f8;
font-size: 0.83rem;
line-height: 1.3;
word-break: break-word;
}
.modal-actions-right { .modal-actions-right {
justify-content: flex-end; justify-content: flex-end;
} }
@ -2210,6 +2251,35 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.request-chat-live-row {
display: inline-flex;
align-items: center;
gap: 0.42rem;
margin: -0.1rem 0 0.55rem;
min-height: 1rem;
}
.chat-live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(68, 208, 138, 0.95);
box-shadow: 0 0 0 3px rgba(68, 208, 138, 0.16);
flex-shrink: 0;
}
.chat-live-dot.degraded {
background: rgba(240, 167, 72, 0.95);
box-shadow: 0 0 0 3px rgba(240, 167, 72, 0.16);
}
.request-chat-live-text {
font-size: 0.77rem;
color: #a8bad0;
line-height: 1.2;
letter-spacing: 0.01em;
}
.request-chat-tabs { .request-chat-tabs {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -2393,6 +2463,11 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0.25rem; gap: 0.25rem;
} }
.request-data-row-client-value {
grid-column: 1 / -1;
flex-wrap: wrap;
}
} }
.chat-message-files { .chat-message-files {

View file

@ -981,6 +981,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
clearPendingStatusChangePreset, clearPendingStatusChangePreset,
submitRequestStatusChange, submitRequestStatusChange,
submitRequestModalMessage, submitRequestModalMessage,
probeRequestLive,
setRequestTyping,
loadRequestDataTemplates, loadRequestDataTemplates,
loadRequestDataBatch, loadRequestDataBatch,
loadRequestDataTemplateDetails, loadRequestDataTemplateDetails,
@ -3129,6 +3131,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
onSaveRequestDataBatch={saveRequestDataBatch} onSaveRequestDataBatch={saveRequestDataBatch}
onChangeStatus={submitRequestStatusChange} onChangeStatus={submitRequestStatusChange}
onConsumePendingStatusChangePreset={clearPendingStatusChangePreset} onConsumePendingStatusChangePreset={clearPendingStatusChangePreset}
onLiveProbe={probeRequestLive}
onTypingSignal={setRequestTyping}
AttachmentPreviewModalComponent={AttachmentPreviewModal} AttachmentPreviewModalComponent={AttachmentPreviewModal}
StatusLineComponent={StatusLine} StatusLineComponent={StatusLine}
/> />

View file

@ -42,6 +42,8 @@ export function RequestWorkspace({
onUploadRequestAttachment, onUploadRequestAttachment,
onChangeStatus, onChangeStatus,
onConsumePendingStatusChangePreset, onConsumePendingStatusChangePreset,
onLiveProbe,
onTypingSignal,
domIds, domIds,
AttachmentPreviewModalComponent, AttachmentPreviewModalComponent,
StatusLineComponent, StatusLineComponent,
@ -97,8 +99,19 @@ export function RequestWorkspace({
status: "", status: "",
error: "", error: "",
}); });
const [composerFocused, setComposerFocused] = useState(false);
const [typingPeers, setTypingPeers] = useState([]);
const [liveMode, setLiveMode] = useState("online");
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const statusChangeFileInputRef = useRef(null); const statusChangeFileInputRef = useRef(null);
const chatListRef = useRef(null);
const liveCursorRef = useRef("");
const liveTimerRef = useRef(null);
const liveInFlightRef = useRef(false);
const liveFailCountRef = useRef(0);
const typingHeartbeatRef = useRef(null);
const typingActiveRef = useRef(false);
const lastAutoScrollCursorRef = useRef("");
const idMap = useMemo( const idMap = useMemo(
() => ({ () => ({
messagesList: "request-modal-messages", messagesList: "request-modal-messages",
@ -367,6 +380,40 @@ export function RequestWorkspace({
return map; return map;
}, [safeAttachments]); }, [safeAttachments]);
const localActivityCursor = useMemo(() => {
let latestTs = 0;
const pickLatest = (value) => {
if (!value) return;
const ts = new Date(value).getTime();
if (Number.isFinite(ts) && ts > latestTs) latestTs = ts;
};
safeMessages.forEach((item) => {
pickLatest(item?.updated_at);
pickLatest(item?.created_at);
});
safeAttachments.forEach((item) => {
pickLatest(item?.updated_at);
pickLatest(item?.created_at);
});
return latestTs > 0 ? new Date(latestTs).toISOString() : "";
}, [safeAttachments, safeMessages]);
const typingHintText = useMemo(() => {
const rows = Array.isArray(typingPeers) ? typingPeers : [];
if (!rows.length) return "";
const labels = rows
.map((item) => String(item?.actor_label || item?.label || "").trim())
.filter(Boolean);
if (!labels.length) return "Собеседник печатает...";
const unique = [];
labels.forEach((label) => {
if (!unique.includes(label)) unique.push(label);
});
if (unique.length === 1) return unique[0] + " печатает...";
if (unique.length === 2) return unique[0] + " и " + unique[1] + " печатают...";
return unique[0] + ", " + unique[1] + " и еще " + String(unique.length - 2) + " печатают...";
}, [typingPeers]);
const openAttachmentFromMessage = (item) => { const openAttachmentFromMessage = (item) => {
if (!item?.download_url) return; if (!item?.download_url) return;
const kind = detectAttachmentPreviewKind(item.file_name, item.mime_type); const kind = detectAttachmentPreviewKind(item.file_name, item.mime_type);
@ -377,6 +424,121 @@ export function RequestWorkspace({
openPreview(item); openPreview(item);
}; };
useEffect(() => {
liveCursorRef.current = localActivityCursor || "";
}, [localActivityCursor, row?.id]);
useEffect(() => {
if (!row || typeof onLiveProbe !== "function") {
setTypingPeers([]);
setLiveMode("online");
if (liveTimerRef.current) {
clearTimeout(liveTimerRef.current);
liveTimerRef.current = null;
}
liveInFlightRef.current = false;
liveFailCountRef.current = 0;
return undefined;
}
let cancelled = false;
const scheduleNext = (ms) => {
if (cancelled) return;
if (liveTimerRef.current) clearTimeout(liveTimerRef.current);
liveTimerRef.current = setTimeout(runProbe, ms);
};
const runProbe = async () => {
if (cancelled || liveInFlightRef.current) return;
liveInFlightRef.current = true;
try {
const payload = await onLiveProbe({ cursor: liveCursorRef.current });
const cursor = String(payload?.cursor || "").trim();
if (cursor) liveCursorRef.current = cursor;
setTypingPeers(Array.isArray(payload?.typing) ? payload.typing : []);
liveFailCountRef.current = 0;
setLiveMode("online");
} catch (_) {
liveFailCountRef.current += 1;
setLiveMode(liveFailCountRef.current >= 3 ? "degraded" : "online");
} finally {
liveInFlightRef.current = false;
const hidden = typeof document !== "undefined" && document.visibilityState === "hidden";
const baseInterval = hidden ? 8000 : 2500;
const failStep = Math.min(5, Math.max(0, liveFailCountRef.current));
const backoffInterval = failStep > 0 ? Math.min(30000, baseInterval * Math.pow(2, failStep - 1)) : baseInterval;
scheduleNext(backoffInterval);
}
};
runProbe();
return () => {
cancelled = true;
if (liveTimerRef.current) {
clearTimeout(liveTimerRef.current);
liveTimerRef.current = null;
}
liveInFlightRef.current = false;
liveFailCountRef.current = 0;
setTypingPeers([]);
setLiveMode("online");
};
}, [onLiveProbe, row, trackNumber]);
const typingEnabled = Boolean(
row &&
typeof onTypingSignal === "function" &&
!loading &&
!fileUploading &&
composerFocused &&
String(messageDraft || "").trim()
);
useEffect(() => {
if (typeof onTypingSignal !== "function" || !row) {
if (typingHeartbeatRef.current) {
clearInterval(typingHeartbeatRef.current);
typingHeartbeatRef.current = null;
}
typingActiveRef.current = false;
return;
}
if (typingEnabled) {
if (!typingActiveRef.current) {
typingActiveRef.current = true;
void onTypingSignal({ typing: true }).catch(() => null);
}
if (!typingHeartbeatRef.current) {
typingHeartbeatRef.current = setInterval(() => {
void onTypingSignal({ typing: true }).catch(() => null);
}, 2500);
}
return;
}
if (typingHeartbeatRef.current) {
clearInterval(typingHeartbeatRef.current);
typingHeartbeatRef.current = null;
}
if (typingActiveRef.current) {
typingActiveRef.current = false;
void onTypingSignal({ typing: false }).catch(() => null);
}
}, [onTypingSignal, row, typingEnabled]);
useEffect(
() => () => {
if (typingHeartbeatRef.current) {
clearInterval(typingHeartbeatRef.current);
typingHeartbeatRef.current = null;
}
if (typingActiveRef.current && typeof onTypingSignal === "function") {
typingActiveRef.current = false;
void onTypingSignal({ typing: false }).catch(() => null);
}
},
[onTypingSignal]
);
const newDataRequestRow = (source) => { const newDataRequestRow = (source) => {
const item = source || {}; const item = source || {};
const label = String(item.label || "").trim(); const label = String(item.label || "").trim();
@ -392,6 +554,7 @@ export function RequestWorkspace({
field_type: fieldType, field_type: fieldType,
document_name: String(item.document_name || "").trim(), document_name: String(item.document_name || "").trim(),
value_text: item.value_text == null ? "" : String(item.value_text), value_text: item.value_text == null ? "" : String(item.value_text),
value_file: item.value_file || null,
is_filled: Boolean(item.is_filled), is_filled: Boolean(item.is_filled),
}; };
}; };
@ -821,15 +984,7 @@ export function RequestWorkspace({
message_id: currentMessageId, message_id: currentMessageId,
items: payloadItems, items: payloadItems,
}); });
setClientDataModal((prev) => ({ closeClientDataModal();
...prev,
saving: false,
status: "Данные сохранены.",
items: (prev.items || []).map((item) => ({
...item,
pendingFile: null,
})),
}));
} catch (error) { } catch (error) {
setClientDataModal((prev) => ({ setClientDataModal((prev) => ({
...prev, ...prev,
@ -956,6 +1111,20 @@ export function RequestWorkspace({
chatTimelineItems.push(entry); chatTimelineItems.push(entry);
}); });
useEffect(() => {
if (chatTab !== "chat") return;
const listNode = chatListRef.current;
if (!listNode) return;
const cursor = String(localActivityCursor || "");
if (!cursor || cursor === lastAutoScrollCursorRef.current) return;
lastAutoScrollCursorRef.current = cursor;
const raf = window.requestAnimationFrame(() => {
if (!chatListRef.current) return;
chatListRef.current.scrollTop = chatListRef.current.scrollHeight;
});
return () => window.cancelAnimationFrame(raf);
}, [chatTab, localActivityCursor]);
const routeNodes = const routeNodes =
Array.isArray(statusRouteNodes) && statusRouteNodes.length Array.isArray(statusRouteNodes) && statusRouteNodes.length
? statusRouteNodes ? statusRouteNodes
@ -972,7 +1141,7 @@ export function RequestWorkspace({
if (!items.length) return <p className="chat-message-text">Запрос</p>; if (!items.length) return <p className="chat-message-text">Запрос</p>;
if (allFilled) { if (allFilled) {
const fileOnly = items.length === 1 && String(items[0]?.field_type || "").toLowerCase() === "file"; const fileOnly = items.length === 1 && String(items[0]?.field_type || "").toLowerCase() === "file";
return <p className="chat-message-text chat-request-data-collapsed">{fileOnly ? "Файл" : "Запрос"}</p>; return <p className="chat-message-text chat-request-data-collapsed">{fileOnly ? "Файл" : "Заполнен"}</p>;
} }
const visibleItems = items.slice(0, 7); const visibleItems = items.slice(0, 7);
const hiddenCount = Math.max(0, items.length - visibleItems.length); const hiddenCount = Math.max(0, items.length - visibleItems.length);
@ -1014,6 +1183,13 @@ export function RequestWorkspace({
return text || "Не заполнено"; return text || "Не заполнено";
}; };
const dataRequestProgress = useMemo(() => {
const rows = Array.isArray(dataRequestModal.rows) ? dataRequestModal.rows : [];
const total = rows.length;
const filled = rows.filter((rowItem) => Boolean(rowItem?.is_filled || String(rowItem?.value_text || "").trim())).length;
return { total, filled };
}, [dataRequestModal.rows]);
return ( return (
<div className="block"> <div className="block">
<div className="request-workspace-layout"> <div className="request-workspace-layout">
@ -1177,6 +1353,10 @@ export function RequestWorkspace({
</button> </button>
</div> </div>
</div> </div>
<div className="request-chat-live-row" aria-live="polite">
<span className={"chat-live-dot" + (liveMode === "degraded" ? " degraded" : "")} />
<span className="request-chat-live-text">{typingHintText || (liveMode === "degraded" ? "Связь нестабильна, включен backoff" : "Онлайн")}</span>
</div>
<input <input
id={idMap.fileInput} id={idMap.fileInput}
@ -1190,7 +1370,7 @@ export function RequestWorkspace({
{chatTab === "chat" ? ( {chatTab === "chat" ? (
<> <>
<ul className="simple-list request-modal-list request-chat-list" id={idMap.messagesList}> <ul className="simple-list request-modal-list request-chat-list" id={idMap.messagesList} ref={chatListRef}>
{chatTimelineItems.length ? ( {chatTimelineItems.length ? (
chatTimelineItems.map((entry) => chatTimelineItems.map((entry) =>
entry.type === "date" ? ( entry.type === "date" ? (
@ -1327,6 +1507,8 @@ export function RequestWorkspace({
placeholder={messagePlaceholder} placeholder={messagePlaceholder}
value={messageDraft} value={messageDraft}
onChange={onMessageChange} onChange={onMessageChange}
onFocus={() => setComposerFocused(true)}
onBlur={() => setComposerFocused(false)}
disabled={loading || fileUploading} disabled={loading || fileUploading}
/> />
<div className="request-drop-hint muted">Перетащите файлы сюда или прикрепите скрепкой</div> <div className="request-drop-hint muted">Перетащите файлы сюда или прикрепите скрепкой</div>
@ -1365,17 +1547,6 @@ export function RequestWorkspace({
Запросить Запросить
</button> </button>
) : null} ) : null}
{canFillRequestData && idMap.fileUploadButton ? (
<button
className="btn secondary btn-sm"
type="button"
id={idMap.fileUploadButton}
onClick={onSendMessage}
disabled={loading || fileUploading || !hasPendingFiles}
>
Загрузить файл
</button>
) : null}
<button <button
className="icon-btn file-action-btn composer-attach-btn" className="icon-btn file-action-btn composer-attach-btn"
type="button" type="button"
@ -1956,6 +2127,13 @@ export function RequestWorkspace({
</div> </div>
</div> </div>
{dataRequestModal.templateStatus ? <div className="status ok">{dataRequestModal.templateStatus}</div> : null} {dataRequestModal.templateStatus ? <div className="status ok">{dataRequestModal.templateStatus}</div> : null}
{canRequestData && dataRequestModal.messageId ? (
<div className="request-data-progress-line">
<span className="request-data-progress-chip">
{"Заполнено клиентом: " + String(dataRequestProgress.filled) + " / " + String(dataRequestProgress.total)}
</span>
</div>
) : null}
<div className="request-data-modal-grid"> <div className="request-data-modal-grid">
<div className="field"> <div className="field">
@ -2122,6 +2300,31 @@ export function RequestWorkspace({
× ×
</button> </button>
</div> </div>
{canRequestData && (rowItem?.is_filled || String(rowItem?.value_text || "").trim()) ? (
<div className="request-data-row-client-value">
<span className="request-data-row-client-label">Заполнено клиентом:</span>
{String(rowItem?.field_type || "").toLowerCase() === "file" ? (
rowItem?.value_file && rowItem.value_file.download_url ? (
<button
type="button"
className="chat-message-file-chip"
onClick={() => openAttachmentFromMessage(rowItem.value_file)}
>
<span className="chat-message-file-icon" aria-hidden="true">📎</span>
<span className="chat-message-file-name">{String(rowItem.value_file.file_name || "Файл")}</span>
</button>
) : (
<span className="muted">Файл добавлен</span>
)
) : (
<span className="request-data-row-client-text">
{String(rowItem?.field_type || "").toLowerCase() === "date"
? fmtDateOnly(rowItem?.value_text)
: String(rowItem?.value_text || "").trim().slice(0, 140)}
</span>
)}
</div>
) : null}
</div> </div>
)) ))
) : ( ) : (

View file

@ -337,6 +337,32 @@ export function useRequestWorkspace(options) {
setRequestModal((prev) => ({ ...prev, pendingStatusChangePreset: null })); setRequestModal((prev) => ({ ...prev, pendingStatusChangePreset: null }));
}, []); }, []);
const probeRequestLive = useCallback(
async ({ cursor } = {}) => {
const requestId = requestModal.requestId;
if (!api || !requestId) return { has_updates: false, typing: [], cursor: null };
const query = cursor ? "?cursor=" + encodeURIComponent(String(cursor)) : "";
const payload = await api("/api/admin/chat/requests/" + requestId + "/live" + query);
if (payload && payload.has_updates) {
await loadRequestModalData(requestId, { showLoading: false });
}
return payload || { has_updates: false, typing: [], cursor: null };
},
[api, loadRequestModalData, requestModal.requestId]
);
const setRequestTyping = useCallback(
async ({ typing } = {}) => {
const requestId = requestModal.requestId;
if (!api || !requestId) return { status: "skipped", typing: false };
return api("/api/admin/chat/requests/" + requestId + "/typing", {
method: "POST",
body: { typing: Boolean(typing) },
});
},
[api, requestModal.requestId]
);
const submitRequestStatusChange = useCallback( const submitRequestStatusChange = useCallback(
async ({ requestId, statusCode, importantDateAt, comment, files } = {}) => { async ({ requestId, statusCode, importantDateAt, comment, files } = {}) => {
if (!api) throw new Error("API недоступен"); if (!api) throw new Error("API недоступен");
@ -425,6 +451,8 @@ export function useRequestWorkspace(options) {
clearPendingStatusChangePreset, clearPendingStatusChangePreset,
submitRequestStatusChange, submitRequestStatusChange,
submitRequestModalMessage, submitRequestModalMessage,
probeRequestLive,
setRequestTyping,
loadRequestDataTemplates, loadRequestDataTemplates,
loadRequestDataBatch, loadRequestDataBatch,
loadRequestDataTemplateDetails, loadRequestDataTemplateDetails,

View file

@ -11,6 +11,6 @@
<div id="client-root"></div> <div id="client-root"></div>
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script> <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script> <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
<script src="/client.js?v=20260227-01"></script> <script src="/client.js?v=20260227-02"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load diff

View file

@ -621,6 +621,41 @@ import { detectAttachmentPreviewKind, fmtShortDateTime } from "./admin/shared/ut
[activeTrack, apiJson, loadRequestWorkspace] [activeTrack, apiJson, loadRequestWorkspace]
); );
const probeLiveState = useCallback(
async ({ cursor } = {}) => {
const track = String(activeTrack || "").trim();
if (!track) return { has_updates: false, typing: [], cursor: null };
const query = cursor ? "?cursor=" + encodeURIComponent(String(cursor)) : "";
const payload = await apiJson(
"/api/public/chat/requests/" + encodeURIComponent(track) + "/live" + query,
null,
"Не удалось получить live-обновления чата"
);
if (payload && payload.has_updates) {
await loadRequestWorkspace(track, false);
}
return payload || { has_updates: false, typing: [], cursor: null };
},
[activeTrack, apiJson, loadRequestWorkspace]
);
const setTypingSignal = useCallback(
async ({ typing } = {}) => {
const track = String(activeTrack || "").trim();
if (!track) return { status: "skipped", typing: false };
return apiJson(
"/api/public/chat/requests/" + encodeURIComponent(track) + "/typing",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ typing: Boolean(typing) }),
},
"Не удалось обновить статус набора"
);
},
[activeTrack, apiJson]
);
const openServiceRequestModal = useCallback((type) => { const openServiceRequestModal = useCallback((type) => {
const normalized = String(type || "").trim().toUpperCase(); const normalized = String(type || "").trim().toUpperCase();
if (!normalized) return; if (!normalized) return;
@ -797,6 +832,8 @@ import { detectAttachmentPreviewKind, fmtShortDateTime } from "./admin/shared/ut
onSaveRequestDataValues={saveRequestDataValues} onSaveRequestDataValues={saveRequestDataValues}
onUploadRequestAttachment={uploadPublicRequestAttachment} onUploadRequestAttachment={uploadPublicRequestAttachment}
onChangeStatus={() => Promise.resolve(null)} onChangeStatus={() => Promise.resolve(null)}
onLiveProbe={probeLiveState}
onTypingSignal={setTypingSignal}
AttachmentPreviewModalComponent={AttachmentPreviewModal} AttachmentPreviewModalComponent={AttachmentPreviewModal}
StatusLineComponent={StatusLine} StatusLineComponent={StatusLine}
domIds={{ domIds={{
@ -805,7 +842,6 @@ import { detectAttachmentPreviewKind, fmtShortDateTime } from "./admin/shared/ut
messageBody: "cabinet-chat-body", messageBody: "cabinet-chat-body",
sendButton: "cabinet-chat-send", sendButton: "cabinet-chat-send",
fileInput: "cabinet-file-input", fileInput: "cabinet-file-input",
fileUploadButton: "cabinet-file-upload",
dataRequestOverlay: "data-request-overlay", dataRequestOverlay: "data-request-overlay",
dataRequestItems: "data-request-items", dataRequestItems: "data-request-items",
dataRequestStatus: "data-request-status", dataRequestStatus: "data-request-status",

View file

@ -259,6 +259,6 @@
</div> </div>
</div> </div>
<script src="/landing.js"></script> <script src="/landing.js?v=20260227-03"></script>
</body> </body>
</html> </html>

View file

@ -379,6 +379,10 @@
}); });
const data = await parseJsonSafe(response); const data = await parseJsonSafe(response);
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить OTP")); if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить OTP"));
const debugCode = String(data?.sms_response?.debug_code || "").trim();
if (debugCode) {
console.info("[OTP DEV] VIEW_REQUEST code:", debugCode);
}
setStatus(accessStatus, "Код отправлен. Проверьте SMS.", "ok"); setStatus(accessStatus, "Код отправлен. Проверьте SMS.", "ok");
} catch (error) { } catch (error) {
setStatus(accessStatus, error?.message || "Не удалось отправить OTP", "error"); setStatus(accessStatus, error?.message || "Не удалось отправить OTP", "error");
@ -443,6 +447,10 @@
}); });
const otpSendData = await parseJsonSafe(otpSend); const otpSendData = await parseJsonSafe(otpSend);
if (!otpSend.ok) throw new Error(apiErrorDetail(otpSendData, "Не удалось отправить OTP")); if (!otpSend.ok) throw new Error(apiErrorDetail(otpSendData, "Не удалось отправить OTP"));
const debugCode = String(otpSendData?.sms_response?.debug_code || "").trim();
if (debugCode) {
console.info("[OTP DEV] CREATE_REQUEST code:", debugCode);
}
const isMocked = Boolean(otpSendData?.sms_response?.mocked) || String(otpSendData?.sms_response?.provider || "") === "mock_sms"; const isMocked = Boolean(otpSendData?.sms_response?.mocked) || String(otpSendData?.sms_response?.provider || "") === "mock_sms";
const code = await requestOtpCode( const code = await requestOtpCode(

Binary file not shown.

View file

@ -300,7 +300,7 @@ async function uploadCabinetFile(page, fileName = "e2e.txt", bodyText = "E2E fil
mimeType, mimeType,
buffer, buffer,
}); });
await page.locator("#cabinet-file-upload").click(); await page.locator("#cabinet-chat-send").click();
try { try {
await expect(page.locator("#client-page-status")).toContainText("Файл загружен.", { timeout: 20_000 }); await expect(page.locator("#client-page-status")).toContainText("Файл загружен.", { timeout: 20_000 });

View file

@ -1,7 +1,16 @@
from tests.admin.base import * # noqa: F401,F403 from tests.admin.base import * # noqa: F401,F403
from app.services.chat_presence import clear_presence_for_tests
class AdminLawyerChatTests(AdminUniversalCrudBase): class AdminLawyerChatTests(AdminUniversalCrudBase):
def setUp(self):
super().setUp()
clear_presence_for_tests()
def tearDown(self):
clear_presence_for_tests()
super().tearDown()
def test_lawyer_permissions_and_request_crud(self): def test_lawyer_permissions_and_request_crud(self):
lawyer_headers = self._auth_headers("LAWYER") lawyer_headers = self._auth_headers("LAWYER")
@ -417,3 +426,103 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
self.assertEqual(admin_create.status_code, 201) self.assertEqual(admin_create.status_code, 201)
self.assertEqual(admin_create.json()["author_type"], "SYSTEM") self.assertEqual(admin_create.json()["author_type"], "SYSTEM")
def test_admin_chat_live_and_typing_endpoints_follow_rbac(self):
with self.SessionLocal() as db:
lawyer_self = AdminUser(
role="LAWYER",
name="Юрист Live Свой",
email="lawyer.live.self@example.com",
password_hash="hash",
is_active=True,
)
lawyer_other = AdminUser(
role="LAWYER",
name="Юрист Live Чужой",
email="lawyer.live.other@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([lawyer_self, lawyer_other])
db.flush()
self_id = str(lawyer_self.id)
other_id = str(lawyer_other.id)
own = Request(
track_number="TRK-CHAT-LIVE-OWN",
client_name="Клиент Live Свой",
client_phone="+79995550101",
status_code="IN_PROGRESS",
description="own",
extra_fields={},
assigned_lawyer_id=self_id,
)
foreign = Request(
track_number="TRK-CHAT-LIVE-FOREIGN",
client_name="Клиент Live Чужой",
client_phone="+79995550102",
status_code="IN_PROGRESS",
description="foreign",
extra_fields={},
assigned_lawyer_id=other_id,
)
unassigned = Request(
track_number="TRK-CHAT-LIVE-UNASSIGNED",
client_name="Клиент Live Без назначения",
client_phone="+79995550103",
status_code="NEW",
description="unassigned",
extra_fields={},
assigned_lawyer_id=None,
)
db.add_all([own, foreign, unassigned])
db.flush()
db.add(Message(request_id=own.id, author_type="CLIENT", author_name="Клиент", body="live start"))
db.commit()
own_id = str(own.id)
foreign_id = str(foreign.id)
unassigned_id = str(unassigned.id)
lawyer_headers = self._auth_headers("LAWYER", email="lawyer.live.self@example.com", sub=self_id)
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
own_live = self.client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers)
self.assertEqual(own_live.status_code, 200)
own_cursor = str(own_live.json().get("cursor") or "")
own_live_no_delta = self.client.get(
f"/api/admin/chat/requests/{own_id}/live",
headers=lawyer_headers,
params={"cursor": own_cursor},
)
self.assertEqual(own_live_no_delta.status_code, 200)
self.assertFalse(bool(own_live_no_delta.json().get("has_updates")))
foreign_live = self.client.get(f"/api/admin/chat/requests/{foreign_id}/live", headers=lawyer_headers)
self.assertEqual(foreign_live.status_code, 403)
own_typing = self.client.post(
f"/api/admin/chat/requests/{own_id}/typing",
headers=lawyer_headers,
json={"typing": True},
)
self.assertEqual(own_typing.status_code, 200)
self.assertTrue(bool(own_typing.json().get("typing")))
unassigned_typing = self.client.post(
f"/api/admin/chat/requests/{unassigned_id}/typing",
headers=lawyer_headers,
json={"typing": True},
)
self.assertEqual(unassigned_typing.status_code, 403)
admin_typing = self.client.post(
f"/api/admin/chat/requests/{own_id}/typing",
headers=admin_headers,
json={"typing": True},
)
self.assertEqual(admin_typing.status_code, 200)
self.assertTrue(bool(admin_typing.json().get("typing")))
own_live_with_typing = self.client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers)
self.assertEqual(own_live_with_typing.status_code, 200)
typing_rows = own_live_with_typing.json().get("typing") or []
self.assertTrue(any(str(item.get("actor_role")) == "ADMIN" for item in typing_rows))

View file

@ -27,6 +27,7 @@ from app.models.notification import Notification
from app.models.request import Request from app.models.request import Request
from app.models.request_data_requirement import RequestDataRequirement from app.models.request_data_requirement import RequestDataRequirement
from app.models.status_history import StatusHistory from app.models.status_history import StatusHistory
from app.services.chat_presence import clear_presence_for_tests, set_typing_presence
class _FakeBody: class _FakeBody:
@ -85,6 +86,7 @@ class PublicCabinetTests(unittest.TestCase):
cls.engine.dispose() cls.engine.dispose()
def setUp(self): def setUp(self):
clear_presence_for_tests()
with self.SessionLocal() as db: with self.SessionLocal() as db:
db.execute(delete(Notification)) db.execute(delete(Notification))
db.execute(delete(StatusHistory)) db.execute(delete(StatusHistory))
@ -107,6 +109,7 @@ class PublicCabinetTests(unittest.TestCase):
def tearDown(self): def tearDown(self):
self.client.close() self.client.close()
app.dependency_overrides.clear() app.dependency_overrides.clear()
clear_presence_for_tests()
@staticmethod @staticmethod
def _public_cookies(track_number: str) -> dict[str, str]: def _public_cookies(track_number: str) -> dict[str, str]:
@ -244,6 +247,66 @@ class PublicCabinetTests(unittest.TestCase):
denied = self.client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER")) denied = self.client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER"))
self.assertEqual(denied.status_code, 403) self.assertEqual(denied.status_code, 403)
def test_public_live_endpoint_and_typing_state(self):
with self.SessionLocal() as db:
req = Request(
track_number="TRK-LIVE-001",
client_name="Клиент Live",
client_phone="+79997771234",
topic_code="consulting",
status_code="NEW",
description="Проверка live",
extra_fields={},
)
db.add(req)
db.flush()
db.add(
Message(
request_id=req.id,
author_type="LAWYER",
author_name="Юрист",
body="Первое сообщение",
)
)
db.commit()
request_id = str(req.id)
cookies = self._public_cookies("TRK-LIVE-001")
live_initial = self.client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies)
self.assertEqual(live_initial.status_code, 200)
live_body = live_initial.json()
self.assertTrue(bool(live_body.get("has_updates")))
self.assertTrue(bool(live_body.get("cursor")))
set_typing_presence(
request_key=request_id,
actor_key="LAWYER:test",
actor_label="Юрист Тест",
actor_role="LAWYER",
typing=True,
)
live_with_typing = self.client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies)
self.assertEqual(live_with_typing.status_code, 200)
typing_rows = live_with_typing.json().get("typing") or []
self.assertTrue(any(str(item.get("actor_label")) == "Юрист Тест" for item in typing_rows))
current_cursor = str(live_with_typing.json().get("cursor") or "")
live_no_delta = self.client.get(
"/api/public/chat/requests/TRK-LIVE-001/live",
params={"cursor": current_cursor},
cookies=cookies,
)
self.assertEqual(live_no_delta.status_code, 200)
self.assertFalse(bool(live_no_delta.json().get("has_updates")))
typing_on = self.client.post(
"/api/public/chat/requests/TRK-LIVE-001/typing",
cookies=cookies,
json={"typing": True},
)
self.assertEqual(typing_on.status_code, 200)
self.assertTrue(bool(typing_on.json().get("typing")))
def test_public_cabinet_respects_track_access(self): def test_public_cabinet_respects_track_access(self):
with self.SessionLocal() as db: with self.SessionLocal() as db:
req = Request( req = Request(

View file

@ -33,6 +33,7 @@ class SmsServiceTests(unittest.TestCase):
payload = send_otp_message(phone="+79990000000", code="111111", purpose="CREATE_REQUEST") payload = send_otp_message(phone="+79990000000", code="111111", purpose="CREATE_REQUEST")
self.assertEqual(payload.get("provider"), "mock_sms") self.assertEqual(payload.get("provider"), "mock_sms")
self.assertTrue(bool(payload.get("dev_mode"))) self.assertTrue(bool(payload.get("dev_mode")))
self.assertEqual(payload.get("debug_code"), "111111")
def test_unknown_provider_raises(self): def test_unknown_provider_raises(self):
settings.SMS_PROVIDER = "unknown" settings.SMS_PROVIDER = "unknown"