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 datetime import datetime, timezone
from uuid import UUID
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.services.chat_service import (
create_admin_or_lawyer_message,
get_chat_activity_summary,
list_messages_for_request,
serialize_message,
serialize_messages_for_request,
)
from app.services.chat_presence import list_typing_presence, set_typing_presence
router = APIRouter()
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:
try:
return UUID(str(request_id))
@ -224,6 +263,62 @@ def create_request_message(
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")
def list_data_request_templates(
request_id: str,

View file

@ -1,4 +1,5 @@
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
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_data_requirement import RequestDataRequirement
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
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:
raw = str(value_text or "").strip()
if not raw:
@ -101,6 +145,58 @@ def create_message_by_track(
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}")
def get_data_request_by_message(
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 fastapi import HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.models.message import Message
@ -212,3 +213,31 @@ def create_admin_or_lawyer_message(
db.commit()
db.refresh(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 importlib.util
import logging
from typing import Any
from app.core.config import settings
@ -11,6 +12,9 @@ class SmsDeliveryError(Exception):
pass
logger = logging.getLogger("uvicorn.error")
def _otp_dev_mode_enabled() -> bool:
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]:
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 {
"provider": "mock_sms",
"status": "accepted",
@ -203,6 +208,7 @@ def send_otp_message(*, phone: str, code: str, purpose: str, track_number: str |
if _otp_dev_mode_enabled():
payload = _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number)
payload["dev_mode"] = True
payload["debug_code"] = str(code)
return payload
provider = str(settings.SMS_PROVIDER or "dummy").strip().lower()

View file

@ -1911,6 +1911,47 @@
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 {
justify-content: flex-end;
}
@ -2210,6 +2251,35 @@
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 {
display: inline-flex;
align-items: center;
@ -2393,6 +2463,11 @@
grid-template-columns: 1fr;
gap: 0.25rem;
}
.request-data-row-client-value {
grid-column: 1 / -1;
flex-wrap: wrap;
}
}
.chat-message-files {

View file

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

View file

@ -42,6 +42,8 @@ export function RequestWorkspace({
onUploadRequestAttachment,
onChangeStatus,
onConsumePendingStatusChangePreset,
onLiveProbe,
onTypingSignal,
domIds,
AttachmentPreviewModalComponent,
StatusLineComponent,
@ -97,8 +99,19 @@ export function RequestWorkspace({
status: "",
error: "",
});
const [composerFocused, setComposerFocused] = useState(false);
const [typingPeers, setTypingPeers] = useState([]);
const [liveMode, setLiveMode] = useState("online");
const fileInputRef = 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(
() => ({
messagesList: "request-modal-messages",
@ -367,6 +380,40 @@ export function RequestWorkspace({
return map;
}, [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) => {
if (!item?.download_url) return;
const kind = detectAttachmentPreviewKind(item.file_name, item.mime_type);
@ -377,6 +424,121 @@ export function RequestWorkspace({
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 item = source || {};
const label = String(item.label || "").trim();
@ -392,6 +554,7 @@ export function RequestWorkspace({
field_type: fieldType,
document_name: String(item.document_name || "").trim(),
value_text: item.value_text == null ? "" : String(item.value_text),
value_file: item.value_file || null,
is_filled: Boolean(item.is_filled),
};
};
@ -821,15 +984,7 @@ export function RequestWorkspace({
message_id: currentMessageId,
items: payloadItems,
});
setClientDataModal((prev) => ({
...prev,
saving: false,
status: "Данные сохранены.",
items: (prev.items || []).map((item) => ({
...item,
pendingFile: null,
})),
}));
closeClientDataModal();
} catch (error) {
setClientDataModal((prev) => ({
...prev,
@ -956,6 +1111,20 @@ export function RequestWorkspace({
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 =
Array.isArray(statusRouteNodes) && statusRouteNodes.length
? statusRouteNodes
@ -972,7 +1141,7 @@ export function RequestWorkspace({
if (!items.length) return <p className="chat-message-text">Запрос</p>;
if (allFilled) {
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 hiddenCount = Math.max(0, items.length - visibleItems.length);
@ -1014,6 +1183,13 @@ export function RequestWorkspace({
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 (
<div className="block">
<div className="request-workspace-layout">
@ -1177,6 +1353,10 @@ export function RequestWorkspace({
</button>
</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
id={idMap.fileInput}
@ -1190,7 +1370,7 @@ export function RequestWorkspace({
{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.map((entry) =>
entry.type === "date" ? (
@ -1327,6 +1507,8 @@ export function RequestWorkspace({
placeholder={messagePlaceholder}
value={messageDraft}
onChange={onMessageChange}
onFocus={() => setComposerFocused(true)}
onBlur={() => setComposerFocused(false)}
disabled={loading || fileUploading}
/>
<div className="request-drop-hint muted">Перетащите файлы сюда или прикрепите скрепкой</div>
@ -1365,17 +1547,6 @@ export function RequestWorkspace({
Запросить
</button>
) : null}
{canFillRequestData && idMap.fileUploadButton ? (
<button
className="btn secondary btn-sm"
type="button"
id={idMap.fileUploadButton}
onClick={onSendMessage}
disabled={loading || fileUploading || !hasPendingFiles}
>
Загрузить файл
</button>
) : null}
<button
className="icon-btn file-action-btn composer-attach-btn"
type="button"
@ -1956,6 +2127,13 @@ export function RequestWorkspace({
</div>
</div>
{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="field">
@ -2122,6 +2300,31 @@ export function RequestWorkspace({
×
</button>
</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>
))
) : (

View file

@ -337,6 +337,32 @@ export function useRequestWorkspace(options) {
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(
async ({ requestId, statusCode, importantDateAt, comment, files } = {}) => {
if (!api) throw new Error("API недоступен");
@ -425,6 +451,8 @@ export function useRequestWorkspace(options) {
clearPendingStatusChangePreset,
submitRequestStatusChange,
submitRequestModalMessage,
probeRequestLive,
setRequestTyping,
loadRequestDataTemplates,
loadRequestDataBatch,
loadRequestDataTemplateDetails,

View file

@ -11,6 +11,6 @@
<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-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>
</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]
);
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 normalized = String(type || "").trim().toUpperCase();
if (!normalized) return;
@ -797,6 +832,8 @@ import { detectAttachmentPreviewKind, fmtShortDateTime } from "./admin/shared/ut
onSaveRequestDataValues={saveRequestDataValues}
onUploadRequestAttachment={uploadPublicRequestAttachment}
onChangeStatus={() => Promise.resolve(null)}
onLiveProbe={probeLiveState}
onTypingSignal={setTypingSignal}
AttachmentPreviewModalComponent={AttachmentPreviewModal}
StatusLineComponent={StatusLine}
domIds={{
@ -805,7 +842,6 @@ import { detectAttachmentPreviewKind, fmtShortDateTime } from "./admin/shared/ut
messageBody: "cabinet-chat-body",
sendButton: "cabinet-chat-send",
fileInput: "cabinet-file-input",
fileUploadButton: "cabinet-file-upload",
dataRequestOverlay: "data-request-overlay",
dataRequestItems: "data-request-items",
dataRequestStatus: "data-request-status",

View file

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

View file

@ -379,6 +379,10 @@
});
const data = await parseJsonSafe(response);
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");
} catch (error) {
setStatus(accessStatus, error?.message || "Не удалось отправить OTP", "error");
@ -443,6 +447,10 @@
});
const otpSendData = await parseJsonSafe(otpSend);
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 code = await requestOtpCode(

Binary file not shown.

View file

@ -300,7 +300,7 @@ async function uploadCabinetFile(page, fileName = "e2e.txt", bodyText = "E2E fil
mimeType,
buffer,
});
await page.locator("#cabinet-file-upload").click();
await page.locator("#cabinet-chat-send").click();
try {
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 app.services.chat_presence import clear_presence_for_tests
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):
lawyer_headers = self._auth_headers("LAWYER")
@ -417,3 +426,103 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
self.assertEqual(admin_create.status_code, 201)
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_data_requirement import RequestDataRequirement
from app.models.status_history import StatusHistory
from app.services.chat_presence import clear_presence_for_tests, set_typing_presence
class _FakeBody:
@ -85,6 +86,7 @@ class PublicCabinetTests(unittest.TestCase):
cls.engine.dispose()
def setUp(self):
clear_presence_for_tests()
with self.SessionLocal() as db:
db.execute(delete(Notification))
db.execute(delete(StatusHistory))
@ -107,6 +109,7 @@ class PublicCabinetTests(unittest.TestCase):
def tearDown(self):
self.client.close()
app.dependency_overrides.clear()
clear_presence_for_tests()
@staticmethod
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"))
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):
with self.SessionLocal() as db:
req = Request(

View file

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