mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 18:13:46 +03:00
fix client chat
This commit is contained in:
parent
69055921cd
commit
7b509f6af3
19 changed files with 3371 additions and 994 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
195
app/services/chat_presence.py
Normal file
195
app/services/chat_presence.py
Normal 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()
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
3273
app/web/client.js
3273
app/web/client.js
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -259,6 +259,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/landing.js"></script>
|
||||
<script src="/landing.js?v=20260227-03"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue