mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
fix speed up 04
This commit is contained in:
parent
6e2c917269
commit
2b7043a89e
24 changed files with 1309 additions and 203 deletions
|
|
@ -12,7 +12,10 @@
|
|||
- `app/api/admin/requests_modules/kanban.py`: kanban aggregation and filters.
|
||||
- `app/api/admin/crud_modules/`: generic CRUD/query layer.
|
||||
- `app/services/`: shared domain services, including chat serialization/security.
|
||||
- `app/services/chat_crypto.py`: versioned chat crypto (`v1/v2` backward-compatible read, `v3` per-chat AEAD write path).
|
||||
- `app/services/chat_secure_service.py`: chat paging, explicit decrypt, message body batches, live delta.
|
||||
- `app/models/`: SQLAlchemy models.
|
||||
- `app/models/message.py`: plaintext no longer auto-decrypts from ORM; chat body encryption happens on flush, read decrypt is explicit.
|
||||
- `app/core/`: config, middleware, security hardening.
|
||||
|
||||
### Frontend Areas
|
||||
|
|
@ -31,3 +34,4 @@
|
|||
- Kanban performance: `app/api/admin/requests_modules/kanban.py`.
|
||||
- Generic query endpoints used by request modal: `app/api/admin/crud_modules/service.py`, `app/api/admin/invoices.py`.
|
||||
- Chat serialization and live updates: `app/services/chat_secure_service.py`, public/admin chat routers.
|
||||
- Chat crypto and migration safety: `app/services/chat_crypto.py`, `app/scripts/reencrypt_with_active_kid.py`, `tests/test_crypto_kid_rotation.py`, `tests/test_reencrypt_with_active_kid.py`.
|
||||
|
|
|
|||
13
Makefile
13
Makefile
|
|
@ -1,10 +1,11 @@
|
|||
.PHONY: \
|
||||
help \
|
||||
local-up local-down local-logs local-migrate local-test local-seed local-seed-statuses local-seed-catalog \
|
||||
local-reencrypt-active-kid \
|
||||
prod-up prod-down prod-logs prod-ps prod-migrate \
|
||||
prod-seed-statuses prod-seed-catalog \
|
||||
prod-secrets-generate prod-secrets-apply prod-secrets-generate-env prod-secrets-apply-env \
|
||||
prod-minio-tls-init incident-checklist rotate-encryption-kid reencrypt-active-kid \
|
||||
prod-minio-tls-init incident-checklist rotate-encryption-kid reencrypt-active-kid prod-reencrypt-active-kid \
|
||||
security-smoke prod-security-audit prod-security-scheduler-up prod-security-scheduler-logs \
|
||||
prod-cert-init prod-cert-renew \
|
||||
check-prod-files check-cert-files \
|
||||
|
|
@ -37,6 +38,7 @@ help:
|
|||
@echo " local-seed - Seed quotes (local)"
|
||||
@echo " local-seed-statuses - Seed legal flow statuses (local)"
|
||||
@echo " local-seed-catalog - Seed quotes + legal flow statuses (local)"
|
||||
@echo " local-reencrypt-active-kid - Re-encrypt historical chat/invoice/admin secrets using active KID (local)"
|
||||
@echo " prod-up - Start production stack (nginx 80/443 + TLS certs already issued)"
|
||||
@echo " prod-down - Stop production stack"
|
||||
@echo " prod-logs - Tail production logs"
|
||||
|
|
@ -48,6 +50,7 @@ help:
|
|||
@echo " prod-secrets-apply - Generate + apply rotated internal secrets to running prod stack"
|
||||
@echo " prod-secrets-generate-env - Generate rotated secrets from current .env into .env.secure"
|
||||
@echo " prod-secrets-apply-env - Generate + apply rotated secrets directly for current .env"
|
||||
@echo " prod-reencrypt-active-kid - Re-encrypt historical chat/invoice/admin secrets using active KID (prod)"
|
||||
@echo " prod-minio-tls-init - Generate internal CA and MinIO TLS certs (deploy/tls/minio)"
|
||||
@echo " incident-checklist - Create PDn incident checklist markdown report"
|
||||
@echo " security-smoke - Run security smoke checks and create report"
|
||||
|
|
@ -95,6 +98,9 @@ local-seed-catalog:
|
|||
$(LOCAL_COMPOSE) exec -T backend python -m app.scripts.upsert_quotes
|
||||
$(LOCAL_COMPOSE) exec -T backend python -m app.scripts.upsert_statuses_legal_flow
|
||||
|
||||
local-reencrypt-active-kid:
|
||||
$(LOCAL_COMPOSE) exec -T backend python -m app.scripts.reencrypt_with_active_kid --apply
|
||||
|
||||
check-prod-files:
|
||||
@test -f docker-compose.prod.nginx.yml || (echo "[ERROR] Missing docker-compose.prod.nginx.yml. Run: git pull"; exit 1)
|
||||
@test -f frontend/nginx.prod.conf || (echo "[ERROR] Missing frontend/nginx.prod.conf. Run: git pull"; exit 1)
|
||||
|
|
@ -177,7 +183,10 @@ rotate-encryption-kid:
|
|||
./scripts/ops/rotate_encryption_kid.sh --env-file .env
|
||||
|
||||
reencrypt-active-kid:
|
||||
docker compose exec -T backend python -m app.scripts.reencrypt_with_active_kid --apply
|
||||
$(MAKE) local-reencrypt-active-kid
|
||||
|
||||
prod-reencrypt-active-kid: check-prod-files
|
||||
$(PROD_COMPOSE) exec -T backend python -m app.scripts.reencrypt_with_active_kid --apply
|
||||
|
||||
# Initial certificate bootstrap:
|
||||
# 1) Start stack with edge nginx on port 80 only.
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ from app.services.chat_secure_service import (
|
|||
list_messages_for_request,
|
||||
mark_messages_delivered_for_staff,
|
||||
mark_messages_read_for_staff,
|
||||
serialize_message,
|
||||
serialize_message_for_request,
|
||||
serialize_message_bodies_for_request,
|
||||
serialize_messages_for_request,
|
||||
)
|
||||
from app.services.chat_presence import list_typing_presence, set_typing_presence
|
||||
|
|
@ -142,6 +143,24 @@ def _parse_uuid_or_400(raw: str, field_name: str) -> UUID:
|
|||
raise HTTPException(status_code=400, detail=f'Некорректное поле "{field_name}"')
|
||||
|
||||
|
||||
def _normalize_message_ids(raw: object, *, field_name: str = "ids", limit: int = 200) -> list[UUID]:
|
||||
if not isinstance(raw, list):
|
||||
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть списком')
|
||||
seen: set[UUID] = set()
|
||||
out: list[UUID] = []
|
||||
for item in raw:
|
||||
value = _parse_uuid_or_400(str(item or ""), field_name)
|
||||
if value in seen:
|
||||
continue
|
||||
seen.add(value)
|
||||
out.append(value)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
if not out:
|
||||
raise HTTPException(status_code=400, detail="Нужно передать хотя бы один идентификатор сообщения")
|
||||
return out
|
||||
|
||||
|
||||
def _slugify_key(raw: str) -> str:
|
||||
text = str(raw or "").strip().lower()
|
||||
out = []
|
||||
|
|
@ -276,7 +295,16 @@ def list_request_messages(
|
|||
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||
mark_messages_read_for_staff(db, request_id=req.id)
|
||||
rows = list_messages_for_request(db, req.id)
|
||||
payload = {"rows": serialize_messages_for_request(db, req.id, rows), "total": len(rows)}
|
||||
payload = {
|
||||
"rows": serialize_messages_for_request(
|
||||
db,
|
||||
req.id,
|
||||
rows,
|
||||
request_extra_fields=req.extra_fields,
|
||||
include_bodies=True,
|
||||
),
|
||||
"total": len(rows),
|
||||
}
|
||||
_audit_admin_chat_read(
|
||||
db,
|
||||
admin=admin,
|
||||
|
|
@ -292,25 +320,36 @@ def list_request_messages(
|
|||
def list_request_messages_window(
|
||||
request_id: str,
|
||||
http_request: FastapiRequest,
|
||||
before_id: str | None = None,
|
||||
before_created_at: str | None = None,
|
||||
before_count: int = 0,
|
||||
limit: int = DEFAULT_CHAT_WINDOW_LIMIT,
|
||||
include_body: bool = True,
|
||||
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)
|
||||
mark_messages_read_for_staff(db, request_id=req.id)
|
||||
rows, total, has_more, loaded_count = list_messages_for_request_window(
|
||||
rows, has_more = list_messages_for_request_window(
|
||||
db,
|
||||
req.id,
|
||||
limit=limit,
|
||||
before_id=before_id,
|
||||
before_created_at=before_created_at,
|
||||
before_count=before_count,
|
||||
)
|
||||
message_total = int(get_chat_activity_summary(db, req.id).get("message_count") or len(rows))
|
||||
payload = {
|
||||
"rows": serialize_messages_for_request(db, req.id, rows),
|
||||
"total": total,
|
||||
"rows": serialize_messages_for_request(
|
||||
db,
|
||||
req.id,
|
||||
rows,
|
||||
request_extra_fields=req.extra_fields,
|
||||
include_bodies=bool(include_body),
|
||||
),
|
||||
"has_more": has_more,
|
||||
"loaded_count": loaded_count,
|
||||
"total": message_total,
|
||||
"limit": clamp_chat_window_limit(limit),
|
||||
}
|
||||
_audit_admin_chat_read(
|
||||
|
|
@ -324,6 +363,44 @@ def list_request_messages_window(
|
|||
return payload
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/message-bodies")
|
||||
def load_request_message_bodies(
|
||||
request_id: str,
|
||||
payload: dict,
|
||||
http_request: FastapiRequest,
|
||||
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)
|
||||
message_ids = _normalize_message_ids((payload or {}).get("ids"))
|
||||
rows = (
|
||||
db.query(Message)
|
||||
.filter(Message.request_id == req.id, Message.id.in_(message_ids))
|
||||
.order_by(Message.created_at.asc(), Message.id.asc())
|
||||
.all()
|
||||
)
|
||||
rows_by_id = {str(row.id): row for row in rows}
|
||||
ordered_rows = [rows_by_id[str(message_id)] for message_id in message_ids if str(message_id) in rows_by_id]
|
||||
result = {
|
||||
"rows": serialize_message_bodies_for_request(
|
||||
db,
|
||||
req.id,
|
||||
ordered_rows,
|
||||
request_extra_fields=req.extra_fields,
|
||||
)
|
||||
}
|
||||
_audit_admin_chat_read(
|
||||
db,
|
||||
admin=admin,
|
||||
http_request=http_request,
|
||||
req=req,
|
||||
action="READ_CHAT_MESSAGES",
|
||||
details={"rows": len(ordered_rows), "body_batch": True},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/messages", status_code=201)
|
||||
def create_request_message(
|
||||
request_id: str,
|
||||
|
|
@ -354,7 +431,7 @@ def create_request_message(
|
|||
actor_name=actor_name,
|
||||
actor_admin_user_id=actor_admin_user_id,
|
||||
)
|
||||
return serialize_message(row)
|
||||
return serialize_message_for_request(row, request_extra_fields=req.extra_fields)
|
||||
|
||||
|
||||
@router.get("/requests/{request_id}/live")
|
||||
|
|
@ -394,7 +471,13 @@ def get_request_live_state(
|
|||
.order_by(Attachment.created_at.asc(), Attachment.id.asc())
|
||||
.all()
|
||||
)
|
||||
delta_messages = serialize_messages_for_request(db, req.id, message_rows)
|
||||
delta_messages = serialize_messages_for_request(
|
||||
db,
|
||||
req.id,
|
||||
message_rows,
|
||||
request_extra_fields=req.extra_fields,
|
||||
include_bodies=True,
|
||||
)
|
||||
delta_attachments = [_serialize_live_attachment(row) for row in attachment_rows]
|
||||
|
||||
actor_sub = str(admin.get("sub") or "").strip() or "unknown"
|
||||
|
|
@ -935,7 +1018,13 @@ def upsert_data_request_batch(
|
|||
|
||||
db.commit()
|
||||
fresh_messages = list_messages_for_request(db, req.id)
|
||||
serialized = serialize_messages_for_request(db, req.id, fresh_messages)
|
||||
serialized = serialize_messages_for_request(
|
||||
db,
|
||||
req.id,
|
||||
fresh_messages,
|
||||
request_extra_fields=req.extra_fields,
|
||||
include_bodies=True,
|
||||
)
|
||||
payload_row = next((item for item in serialized if str(item.get("id")) == str(message_uuid)), None)
|
||||
if payload_row is None:
|
||||
raise HTTPException(status_code=500, detail="Не удалось сформировать сообщение запроса")
|
||||
|
|
|
|||
|
|
@ -108,10 +108,11 @@ def get_request(
|
|||
def get_request_workspace(
|
||||
request_id: str,
|
||||
http_request: FastapiRequest,
|
||||
include_related: bool = Query(default=True),
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||
):
|
||||
payload = get_request_workspace_service(request_id, db, admin)
|
||||
payload = get_request_workspace_service(request_id, db, admin, include_related=include_related)
|
||||
request_payload = payload.get("request") or {}
|
||||
record_pii_access_event(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from time import perf_counter
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
|
@ -18,7 +20,7 @@ from app.models.request import Request
|
|||
from app.models.request_service_request import RequestServiceRequest
|
||||
from app.schemas.admin import RequestAdminCreate, RequestAdminPatch
|
||||
from app.services.chat_secure_service import (
|
||||
DEFAULT_CHAT_WINDOW_LIMIT,
|
||||
get_chat_activity_summary,
|
||||
list_messages_for_request_window,
|
||||
mark_messages_read_for_staff,
|
||||
serialize_messages_for_request,
|
||||
|
|
@ -54,6 +56,9 @@ from .permissions import (
|
|||
)
|
||||
from .status_flow import apply_request_special_filters, get_request_status_route_service, split_request_special_filters
|
||||
|
||||
_WORKSPACE_LOG = logging.getLogger("uvicorn.error")
|
||||
INITIAL_WORKSPACE_CHAT_WINDOW_LIMIT = 20
|
||||
|
||||
|
||||
def query_requests_service(uq: UniversalQuery, db: Session, admin: dict) -> dict[str, Any]:
|
||||
base_query = db.query(Request)
|
||||
|
|
@ -459,36 +464,76 @@ def _serialize_request_invoice(row: Invoice) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def get_request_workspace_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||||
def get_request_workspace_service(request_id: str, db: Session, admin: dict, *, include_related: bool = True) -> dict[str, Any]:
|
||||
started_at = perf_counter()
|
||||
request_uuid = request_uuid_or_400(request_id)
|
||||
req = db.get(Request, request_uuid)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
ensure_lawyer_can_view_request_or_403(admin, req)
|
||||
|
||||
side_effects_started_at = perf_counter()
|
||||
_apply_request_open_side_effects(db, req, admin, mark_chat_read=True)
|
||||
side_effects_ms = (perf_counter() - side_effects_started_at) * 1000.0
|
||||
|
||||
serialize_request_started_at = perf_counter()
|
||||
request_payload = _serialize_request_row(req)
|
||||
message_rows, messages_total, messages_has_more, messages_loaded_count = list_messages_for_request_window(
|
||||
request_row_ms = (perf_counter() - serialize_request_started_at) * 1000.0
|
||||
|
||||
messages_started_at = perf_counter()
|
||||
message_rows, messages_has_more = list_messages_for_request_window(
|
||||
db,
|
||||
req.id,
|
||||
limit=DEFAULT_CHAT_WINDOW_LIMIT,
|
||||
limit=INITIAL_WORKSPACE_CHAT_WINDOW_LIMIT,
|
||||
before_count=0,
|
||||
)
|
||||
messages_query_ms = (perf_counter() - messages_started_at) * 1000.0
|
||||
|
||||
serialize_messages_started_at = perf_counter()
|
||||
serialized_messages = serialize_messages_for_request(
|
||||
db,
|
||||
req.id,
|
||||
message_rows,
|
||||
request_extra_fields=req.extra_fields,
|
||||
include_bodies=False,
|
||||
)
|
||||
serialize_messages_ms = (perf_counter() - serialize_messages_started_at) * 1000.0
|
||||
messages_total = int(get_chat_activity_summary(db, req.id).get("message_count") or len(message_rows))
|
||||
messages_loaded_count = len(message_rows)
|
||||
|
||||
attachment_rows: list[Attachment] = []
|
||||
invoice_rows: list[Invoice] = []
|
||||
status_route_payload: dict[str, Any] = {
|
||||
"nodes": [],
|
||||
"history": [],
|
||||
"available_statuses": [],
|
||||
"current_important_date_at": request_payload.get("important_date_at"),
|
||||
}
|
||||
attachments_query_ms = 0.0
|
||||
invoices_query_ms = 0.0
|
||||
status_route_ms = 0.0
|
||||
if include_related:
|
||||
attachments_started_at = perf_counter()
|
||||
attachment_rows = (
|
||||
db.query(Attachment)
|
||||
.filter(Attachment.request_id == req.id)
|
||||
.order_by(Attachment.created_at.asc(), Attachment.id.asc())
|
||||
.all()
|
||||
)
|
||||
attachments_query_ms = (perf_counter() - attachments_started_at) * 1000.0
|
||||
role = str(admin.get("role") or "").upper()
|
||||
invoice_rows: list[Invoice] = []
|
||||
if role in {"ADMIN", "LAWYER"}:
|
||||
invoices_started_at = perf_counter()
|
||||
invoice_rows = (
|
||||
db.query(Invoice)
|
||||
.filter(Invoice.request_id == req.id)
|
||||
.order_by(Invoice.issued_at.desc(), Invoice.id.desc())
|
||||
.all()
|
||||
)
|
||||
invoices_query_ms = (perf_counter() - invoices_started_at) * 1000.0
|
||||
status_route_started_at = perf_counter()
|
||||
status_route_payload = get_request_status_route_service(request_id, db, admin, request_row=req)
|
||||
status_route_ms = (perf_counter() - status_route_started_at) * 1000.0
|
||||
|
||||
paid_invoices = [row for row in invoice_rows if str(row.status or "").upper() == "PAID"]
|
||||
paid_total = round(sum(float(row.amount or 0) for row in paid_invoices), 2)
|
||||
|
|
@ -499,9 +544,9 @@ def get_request_workspace_service(request_id: str, db: Session, admin: dict) ->
|
|||
if latest_paid_at is None or row.paid_at > latest_paid_at:
|
||||
latest_paid_at = row.paid_at
|
||||
|
||||
return {
|
||||
payload = {
|
||||
"request": request_payload,
|
||||
"messages": serialize_messages_for_request(db, req.id, message_rows),
|
||||
"messages": serialized_messages,
|
||||
"messages_total": messages_total,
|
||||
"messages_has_more": messages_has_more,
|
||||
"messages_loaded_count": messages_loaded_count,
|
||||
|
|
@ -513,8 +558,29 @@ def get_request_workspace_service(request_id: str, db: Session, admin: dict) ->
|
|||
"paid_total": paid_total,
|
||||
"last_paid_at": latest_paid_at.isoformat() if latest_paid_at else request_payload.get("paid_at"),
|
||||
},
|
||||
"status_route": get_request_status_route_service(request_id, db, admin, request_row=req),
|
||||
"status_route": status_route_payload,
|
||||
}
|
||||
total_ms = (perf_counter() - started_at) * 1000.0
|
||||
_WORKSPACE_LOG.info(
|
||||
"workspace request_id=%s include_related=%s total_ms=%.2f side_effects_ms=%.2f request_row_ms=%.2f "
|
||||
"messages_query_ms=%.2f serialize_messages_ms=%.2f attachments_query_ms=%.2f invoices_query_ms=%.2f "
|
||||
"status_route_ms=%.2f messages=%s attachments=%s invoices=%s messages_total=%s",
|
||||
str(req.id),
|
||||
bool(include_related),
|
||||
total_ms,
|
||||
side_effects_ms,
|
||||
request_row_ms,
|
||||
messages_query_ms,
|
||||
serialize_messages_ms,
|
||||
attachments_query_ms,
|
||||
invoices_query_ms,
|
||||
status_route_ms,
|
||||
len(message_rows),
|
||||
len(attachment_rows),
|
||||
len(invoice_rows),
|
||||
messages_total,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def claim_request_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from time import perf_counter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
|
@ -29,6 +31,8 @@ from app.services.status_transition_requirements import validate_transition_requ
|
|||
from .common import normalize_important_date_or_default, parse_datetime_safe
|
||||
from .permissions import ensure_lawyer_can_manage_request_or_403, ensure_lawyer_can_view_request_or_403, request_uuid_or_400
|
||||
|
||||
_STATUS_ROUTE_LOG = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
def terminal_status_codes(db: Session) -> set[str]:
|
||||
rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
|
||||
|
|
@ -215,6 +219,7 @@ def get_request_status_route_service(
|
|||
admin: dict,
|
||||
request_row: Request | None = None,
|
||||
) -> dict[str, Any]:
|
||||
started_at = perf_counter()
|
||||
req = request_row
|
||||
if req is None:
|
||||
request_uuid = request_uuid_or_400(request_id)
|
||||
|
|
@ -226,12 +231,14 @@ def get_request_status_route_service(
|
|||
topic_code = str(req.topic_code or "").strip()
|
||||
current_status = str(req.status_code or "").strip()
|
||||
|
||||
history_started_at = perf_counter()
|
||||
history_rows = (
|
||||
db.query(StatusHistory)
|
||||
.filter(StatusHistory.request_id == req.id)
|
||||
.order_by(StatusHistory.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
history_ms = (perf_counter() - history_started_at) * 1000.0
|
||||
|
||||
known_codes: set[str] = set()
|
||||
if current_status:
|
||||
|
|
@ -244,23 +251,27 @@ def get_request_status_route_service(
|
|||
if to_code:
|
||||
known_codes.add(to_code)
|
||||
statuses_map: dict[str, dict[str, Any]] = {}
|
||||
enabled_statuses_started_at = perf_counter()
|
||||
all_enabled_status_rows = (
|
||||
db.query(Status, StatusGroup)
|
||||
.outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
|
||||
.filter(Status.enabled.is_(True))
|
||||
.all()
|
||||
)
|
||||
enabled_statuses_ms = (perf_counter() - enabled_statuses_started_at) * 1000.0
|
||||
for status_row, _group_row in all_enabled_status_rows:
|
||||
code = str(status_row.code or "").strip()
|
||||
if code:
|
||||
known_codes.add(code)
|
||||
if known_codes:
|
||||
statuses_meta_started_at = perf_counter()
|
||||
status_rows = (
|
||||
db.query(Status, StatusGroup)
|
||||
.outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
|
||||
.filter(Status.code.in_(list(known_codes)))
|
||||
.all()
|
||||
)
|
||||
statuses_meta_ms = (perf_counter() - statuses_meta_started_at) * 1000.0
|
||||
statuses_map = {
|
||||
str(status_row.code): {
|
||||
"name": str(status_row.name or status_row.code),
|
||||
|
|
@ -271,7 +282,10 @@ def get_request_status_route_service(
|
|||
}
|
||||
for status_row, group_row in status_rows
|
||||
}
|
||||
else:
|
||||
statuses_meta_ms = 0.0
|
||||
|
||||
transitions_started_at = perf_counter()
|
||||
transition_rows = (
|
||||
db.query(TopicStatusTransition)
|
||||
.filter(
|
||||
|
|
@ -283,6 +297,7 @@ def get_request_status_route_service(
|
|||
if topic_code
|
||||
else []
|
||||
)
|
||||
transitions_ms = (perf_counter() - transitions_started_at) * 1000.0
|
||||
transition_sla_by_edge: dict[tuple[str, str], int] = {}
|
||||
outgoing_by_status: dict[str, list[str]] = {}
|
||||
incoming_sla_by_status: dict[str, int] = {}
|
||||
|
|
@ -479,7 +494,7 @@ def get_request_status_route_service(
|
|||
}
|
||||
)
|
||||
|
||||
return {
|
||||
payload = {
|
||||
"request_id": str(req.id),
|
||||
"track_number": req.track_number,
|
||||
"topic_code": req.topic_code,
|
||||
|
|
@ -489,3 +504,19 @@ def get_request_status_route_service(
|
|||
"history": list(reversed(history_entries)),
|
||||
"nodes": nodes,
|
||||
}
|
||||
total_ms = (perf_counter() - started_at) * 1000.0
|
||||
_STATUS_ROUTE_LOG.info(
|
||||
"status_route request_id=%s total_ms=%.2f history_ms=%.2f enabled_statuses_ms=%.2f statuses_meta_ms=%.2f "
|
||||
"transitions_ms=%.2f history_rows=%s known_codes=%s transition_rows=%s nodes=%s",
|
||||
str(req.id),
|
||||
total_ms,
|
||||
history_ms,
|
||||
enabled_statuses_ms,
|
||||
statuses_meta_ms,
|
||||
transitions_ms,
|
||||
len(history_rows),
|
||||
len(known_codes),
|
||||
len(transition_rows),
|
||||
len(nodes),
|
||||
)
|
||||
return payload
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ from app.services.chat_secure_service import (
|
|||
list_messages_for_request,
|
||||
mark_messages_delivered_for_client,
|
||||
mark_messages_read_for_client,
|
||||
serialize_message,
|
||||
serialize_message_for_request,
|
||||
serialize_message_bodies_for_request,
|
||||
serialize_messages_for_request,
|
||||
)
|
||||
from app.services.request_read_markers import EVENT_REQUEST_DATA, mark_unread_for_lawyer
|
||||
|
|
@ -175,6 +176,27 @@ def _ensure_view_access_or_403(session: dict, req: Request) -> None:
|
|||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
|
||||
|
||||
def _normalize_message_ids(raw: object, *, field_name: str = "ids", limit: int = 200) -> list[UUID]:
|
||||
if not isinstance(raw, list):
|
||||
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть списком')
|
||||
seen: set[UUID] = set()
|
||||
out: list[UUID] = []
|
||||
for item in raw:
|
||||
try:
|
||||
value = UUID(str(item or ""))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f'Некорректное поле "{field_name}"')
|
||||
if value in seen:
|
||||
continue
|
||||
seen.add(value)
|
||||
out.append(value)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
if not out:
|
||||
raise HTTPException(status_code=400, detail="Нужно передать хотя бы один идентификатор сообщения")
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/requests/{track_number}/messages")
|
||||
def list_messages_by_track(
|
||||
track_number: str,
|
||||
|
|
@ -186,7 +208,13 @@ def list_messages_by_track(
|
|||
_ensure_view_access_or_403(session, req)
|
||||
mark_messages_read_for_client(db, request_id=req.id)
|
||||
rows = list_messages_for_request(db, req.id)
|
||||
payload = serialize_messages_for_request(db, req.id, rows)
|
||||
payload = serialize_messages_for_request(
|
||||
db,
|
||||
req.id,
|
||||
rows,
|
||||
request_extra_fields=req.extra_fields,
|
||||
include_bodies=True,
|
||||
)
|
||||
_audit_public_chat_read(
|
||||
db,
|
||||
session=session,
|
||||
|
|
@ -202,25 +230,36 @@ def list_messages_by_track(
|
|||
def list_messages_window_by_track(
|
||||
track_number: str,
|
||||
http_request: FastapiRequest,
|
||||
before_id: str | None = None,
|
||||
before_created_at: str | None = None,
|
||||
before_count: int = 0,
|
||||
limit: int = DEFAULT_CHAT_WINDOW_LIMIT,
|
||||
include_body: bool = True,
|
||||
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)
|
||||
mark_messages_read_for_client(db, request_id=req.id)
|
||||
rows, total, has_more, loaded_count = list_messages_for_request_window(
|
||||
rows, has_more = list_messages_for_request_window(
|
||||
db,
|
||||
req.id,
|
||||
limit=limit,
|
||||
before_id=before_id,
|
||||
before_created_at=before_created_at,
|
||||
before_count=before_count,
|
||||
)
|
||||
message_total = int(get_chat_activity_summary(db, req.id).get("message_count") or len(rows))
|
||||
payload = {
|
||||
"rows": serialize_messages_for_request(db, req.id, rows),
|
||||
"total": total,
|
||||
"rows": serialize_messages_for_request(
|
||||
db,
|
||||
req.id,
|
||||
rows,
|
||||
request_extra_fields=req.extra_fields,
|
||||
include_bodies=bool(include_body),
|
||||
),
|
||||
"has_more": has_more,
|
||||
"loaded_count": loaded_count,
|
||||
"total": message_total,
|
||||
"limit": clamp_chat_window_limit(limit),
|
||||
}
|
||||
_audit_public_chat_read(
|
||||
|
|
@ -234,6 +273,44 @@ def list_messages_window_by_track(
|
|||
return payload
|
||||
|
||||
|
||||
@router.post("/requests/{track_number}/message-bodies")
|
||||
def load_message_bodies_by_track(
|
||||
track_number: str,
|
||||
payload: dict,
|
||||
http_request: FastapiRequest,
|
||||
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)
|
||||
message_ids = _normalize_message_ids((payload or {}).get("ids"))
|
||||
rows = (
|
||||
db.query(Message)
|
||||
.filter(Message.request_id == req.id, Message.id.in_(message_ids))
|
||||
.order_by(Message.created_at.asc(), Message.id.asc())
|
||||
.all()
|
||||
)
|
||||
rows_by_id = {str(row.id): row for row in rows}
|
||||
ordered_rows = [rows_by_id[str(message_id)] for message_id in message_ids if str(message_id) in rows_by_id]
|
||||
result = {
|
||||
"rows": serialize_message_bodies_for_request(
|
||||
db,
|
||||
req.id,
|
||||
ordered_rows,
|
||||
request_extra_fields=req.extra_fields,
|
||||
)
|
||||
}
|
||||
_audit_public_chat_read(
|
||||
db,
|
||||
session=session,
|
||||
http_request=http_request,
|
||||
req=req,
|
||||
action="READ_CHAT_MESSAGES",
|
||||
details={"rows": len(ordered_rows), "body_batch": True},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/requests/{track_number}/messages", status_code=201)
|
||||
def create_message_by_track(
|
||||
track_number: str,
|
||||
|
|
@ -246,7 +323,7 @@ def create_message_by_track(
|
|||
req = _request_for_track_or_404(db, track_number)
|
||||
_ensure_view_access_or_403(session, req)
|
||||
row = create_client_message(db, request=req, body=payload.body)
|
||||
return serialize_message(row)
|
||||
return serialize_message_for_request(row, request_extra_fields=req.extra_fields)
|
||||
|
||||
|
||||
@router.get("/requests/{track_number}/live")
|
||||
|
|
@ -286,7 +363,13 @@ def get_live_chat_state_by_track(
|
|||
.order_by(Attachment.created_at.asc(), Attachment.id.asc())
|
||||
.all()
|
||||
)
|
||||
delta_messages = serialize_messages_for_request(db, req.id, message_rows)
|
||||
delta_messages = serialize_messages_for_request(
|
||||
db,
|
||||
req.id,
|
||||
message_rows,
|
||||
request_extra_fields=req.extra_fields,
|
||||
include_bodies=True,
|
||||
)
|
||||
delta_attachments = [_serialize_public_attachment(row) for row in attachment_rows]
|
||||
|
||||
subject = _require_view_session_or_403(session)
|
||||
|
|
@ -492,6 +575,12 @@ def save_data_request_values(
|
|||
db.rollback()
|
||||
|
||||
messages = list_messages_for_request(db, req.id)
|
||||
serialized = serialize_messages_for_request(db, req.id, messages)
|
||||
serialized = serialize_messages_for_request(
|
||||
db,
|
||||
req.id,
|
||||
messages,
|
||||
request_extra_fields=req.extra_fields,
|
||||
include_bodies=True,
|
||||
)
|
||||
current = next((item for item in serialized if str(item.get("id")) == str(message_uuid)), None)
|
||||
return {"updated": updated, "message": current}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ _PERF_PATH_PATTERNS = (
|
|||
("admin_request_detail", re.compile(r"^/api/admin/crud/requests/[^/]+$")),
|
||||
("admin_chat_messages", re.compile(r"^/api/admin/chat/requests/[^/]+/messages$")),
|
||||
("admin_chat_messages_window", re.compile(r"^/api/admin/chat/requests/[^/]+/messages-window$")),
|
||||
("admin_chat_message_bodies", re.compile(r"^/api/admin/chat/requests/[^/]+/message-bodies$")),
|
||||
("admin_chat_live", re.compile(r"^/api/admin/chat/requests/[^/]+/live$")),
|
||||
("admin_request_status_route", re.compile(r"^/api/admin/requests/[^/]+/status-route$")),
|
||||
("admin_request_attachments_query", re.compile(r"^/api/admin/uploads/request-attachments/[^/]+$")),
|
||||
|
|
@ -76,6 +77,7 @@ _PERF_PATH_PATTERNS = (
|
|||
("public_request_detail", re.compile(r"^/api/public/requests/[^/]+$")),
|
||||
("public_chat_messages", re.compile(r"^/api/public/chat/requests/[^/]+/messages$")),
|
||||
("public_chat_messages_window", re.compile(r"^/api/public/chat/requests/[^/]+/messages-window$")),
|
||||
("public_chat_message_bodies", re.compile(r"^/api/public/chat/requests/[^/]+/message-bodies$")),
|
||||
("public_chat_live", re.compile(r"^/api/public/chat/requests/[^/]+/live$")),
|
||||
("public_request_attachments", re.compile(r"^/api/public/requests/[^/]+/attachments$")),
|
||||
("public_request_invoices", re.compile(r"^/api/public/requests/[^/]+/invoices$")),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import String, Boolean, DateTime, Index, Text, event
|
||||
from sqlalchemy.orm import Mapped, Session as OrmSession, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.db.session import Base
|
||||
from app.db.encrypted_types import EncryptedChatText
|
||||
from app.models.common import UUIDMixin, TimestampMixin
|
||||
from app.models.request import Request
|
||||
from app.services.chat_crypto import encrypt_message_body, encrypt_message_body_for_request, is_encrypted_message
|
||||
|
||||
class Message(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "messages"
|
||||
|
|
@ -16,9 +17,45 @@ class Message(Base, UUIDMixin, TimestampMixin):
|
|||
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||||
author_type: Mapped[str] = mapped_column(String(20), nullable=False) # CLIENT|LAWYER|SYSTEM
|
||||
author_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
body: Mapped[str | None] = mapped_column(EncryptedChatText(), nullable=True)
|
||||
body: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
delivered_to_client_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
delivered_to_staff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
read_by_client_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
read_by_staff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
def _find_request_for_message(session: OrmSession, request_id: uuid.UUID | None) -> Request | None:
|
||||
if request_id is None:
|
||||
return None
|
||||
for obj in session.new:
|
||||
if isinstance(obj, Request) and obj.id == request_id:
|
||||
return obj
|
||||
for obj in session.identity_map.values():
|
||||
if isinstance(obj, Request) and obj.id == request_id:
|
||||
return obj
|
||||
return session.get(Request, request_id)
|
||||
|
||||
|
||||
@event.listens_for(OrmSession, "before_flush")
|
||||
def _encrypt_message_bodies_before_flush(session: OrmSession, flush_context, instances) -> None:
|
||||
candidates = [obj for obj in session.new if isinstance(obj, Message)]
|
||||
candidates.extend(obj for obj in session.dirty if isinstance(obj, Message))
|
||||
for message in candidates:
|
||||
raw_body = message.body
|
||||
if raw_body is None:
|
||||
continue
|
||||
text = str(raw_body)
|
||||
if not text or is_encrypted_message(text):
|
||||
continue
|
||||
request_row = _find_request_for_message(session, getattr(message, "request_id", None))
|
||||
if request_row is None:
|
||||
message.body = encrypt_message_body(text)
|
||||
continue
|
||||
encrypted_body, next_extra_fields, changed = encrypt_message_body_for_request(
|
||||
text,
|
||||
request_extra_fields=request_row.extra_fields,
|
||||
)
|
||||
message.body = encrypted_body
|
||||
if changed:
|
||||
request_row.extra_fields = next_extra_fields
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.invoice import Invoice
|
||||
from app.services.chat_crypto import active_chat_kid, decrypt_message_body, encrypt_message_body, extract_message_kid, is_encrypted_message
|
||||
from app.models.request import Request
|
||||
from app.services.chat_crypto import decrypt_message_body, encrypt_message_body
|
||||
from app.services.invoice_crypto import active_requisites_kid, decrypt_requisites, encrypt_requisites, extract_requisites_kid
|
||||
|
||||
|
||||
|
|
@ -28,8 +30,6 @@ def reencrypt_with_active_kid(*, dry_run: bool = True) -> dict[str, int]:
|
|||
"errors": 0,
|
||||
}
|
||||
current_data_kid = active_requisites_kid()
|
||||
current_chat_kid = active_chat_kid()
|
||||
|
||||
try:
|
||||
invoice_rows = db.query(Invoice).all()
|
||||
counts["invoices_total"] = len(invoice_rows)
|
||||
|
|
@ -63,17 +63,39 @@ def reencrypt_with_active_kid(*, dry_run: bool = True) -> dict[str, int]:
|
|||
except Exception:
|
||||
counts["errors"] += 1
|
||||
|
||||
message_rows = db.execute(text("SELECT id, body FROM messages")).all()
|
||||
message_rows = db.execute(text("SELECT id, request_id, body FROM messages")).all()
|
||||
counts["messages_total"] = len(message_rows)
|
||||
for message_id, body in message_rows:
|
||||
for message_id, request_id, body in message_rows:
|
||||
raw_body = str(body or "")
|
||||
if not raw_body:
|
||||
continue
|
||||
if extract_message_kid(raw_body) == current_chat_kid:
|
||||
if raw_body.startswith("chatenc:v3:"):
|
||||
continue
|
||||
try:
|
||||
request_key = None
|
||||
if request_id:
|
||||
try:
|
||||
request_key = UUID(str(request_id))
|
||||
except (TypeError, ValueError):
|
||||
request_key = None
|
||||
request_row = db.get(Request, request_key) if request_key else None
|
||||
if request_row is None:
|
||||
plaintext = decrypt_message_body(raw_body)
|
||||
updated = encrypt_message_body(plaintext)
|
||||
else:
|
||||
from app.services.chat_crypto import decrypt_message_body_for_request, encrypt_message_body_for_request
|
||||
|
||||
plaintext = (
|
||||
decrypt_message_body_for_request(raw_body, request_extra_fields=request_row.extra_fields)
|
||||
if raw_body.startswith("chatenc:v3:")
|
||||
else decrypt_message_body(raw_body)
|
||||
)
|
||||
updated, next_extra_fields, changed = encrypt_message_body_for_request(
|
||||
plaintext,
|
||||
request_extra_fields=request_row.extra_fields,
|
||||
)
|
||||
if changed:
|
||||
request_row.extra_fields = next_extra_fields
|
||||
if updated == raw_body:
|
||||
continue
|
||||
db.execute(
|
||||
|
|
|
|||
|
|
@ -4,32 +4,107 @@ import base64
|
|||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
from app.services.crypto_keyring import get_chat_secrets, key_digest, ordered_unique_key_digests
|
||||
|
||||
_VERSION_LEGACY = b"v1"
|
||||
_PREFIX_LEGACY = "chatenc:v1:"
|
||||
_PREFIX_V2 = "chatenc:v2:"
|
||||
_PREFIX_V3 = "chatenc:v3:"
|
||||
_CHAT_CRYPTO_EXTRA_FIELDS_KEY = "chat_crypto"
|
||||
|
||||
|
||||
def _xor_bytes(a: bytes, b: bytes) -> bytes:
|
||||
return bytes(x ^ y for x, y in zip(a, b))
|
||||
|
||||
|
||||
def _urlsafe_b64encode(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).decode("ascii")
|
||||
|
||||
|
||||
def _urlsafe_b64decode(value: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(str(value or "").encode("ascii"))
|
||||
|
||||
|
||||
def _aad_v2(kid: str) -> bytes:
|
||||
return b"v2|" + str(kid).encode("utf-8") + b"|"
|
||||
|
||||
|
||||
def _aad_v3_message(kid: str) -> bytes:
|
||||
return b"v3|message|" + str(kid).encode("utf-8") + b"|"
|
||||
|
||||
|
||||
def _aad_v3_wrapped_key(kid: str) -> bytes:
|
||||
return b"v3|chat-key|" + str(kid).encode("utf-8") + b"|"
|
||||
|
||||
|
||||
def active_chat_kid() -> str:
|
||||
active_kid, _ = get_chat_secrets()
|
||||
return active_kid
|
||||
|
||||
|
||||
def _active_chat_secret() -> tuple[str, str]:
|
||||
active_kid, key_map = get_chat_secrets()
|
||||
active_secret = key_map.get(active_kid)
|
||||
if not active_secret and key_map:
|
||||
active_secret = next(iter(key_map.values()))
|
||||
if not active_secret:
|
||||
raise ValueError("Не найден активный ключ шифрования чата")
|
||||
return active_kid, active_secret
|
||||
|
||||
|
||||
def _chat_payload_or_none(extra_fields: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
payload = (extra_fields or {}).get(_CHAT_CRYPTO_EXTRA_FIELDS_KEY)
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def _wrap_chat_key(chat_key: bytes, *, kid: str, secret: str) -> dict[str, Any]:
|
||||
nonce = secrets.token_bytes(12)
|
||||
payload = AESGCM(key_digest(secret)).encrypt(nonce, chat_key, _aad_v3_wrapped_key(kid))
|
||||
return {
|
||||
"version": 1,
|
||||
"kek_kid": str(kid),
|
||||
"nonce": _urlsafe_b64encode(nonce),
|
||||
"wrapped_key": _urlsafe_b64encode(payload),
|
||||
}
|
||||
|
||||
|
||||
def _unwrap_chat_key(payload: dict[str, Any], *, key_map: dict[str, str]) -> tuple[bytes, str]:
|
||||
if int(payload.get("version") or 0) != 1:
|
||||
raise ValueError("Неподдерживаемая версия ключа чата")
|
||||
kid = str(payload.get("kek_kid") or "").strip()
|
||||
nonce = _urlsafe_b64decode(str(payload.get("nonce") or ""))
|
||||
wrapped_key = _urlsafe_b64decode(str(payload.get("wrapped_key") or ""))
|
||||
if len(nonce) != 12 or not wrapped_key:
|
||||
raise ValueError("Некорректный формат ключа чата")
|
||||
|
||||
candidate_secrets: list[tuple[str, str]] = []
|
||||
if kid and kid in key_map:
|
||||
candidate_secrets.append((kid, key_map[kid]))
|
||||
for fallback_kid, secret in key_map.items():
|
||||
if kid and fallback_kid == kid:
|
||||
continue
|
||||
candidate_secrets.append((fallback_kid, secret))
|
||||
|
||||
for candidate_kid, secret in candidate_secrets:
|
||||
try:
|
||||
plaintext = AESGCM(key_digest(secret)).decrypt(nonce, wrapped_key, _aad_v3_wrapped_key(kid or candidate_kid))
|
||||
except Exception:
|
||||
continue
|
||||
if len(plaintext) not in {16, 24, 32}:
|
||||
raise ValueError("Некорректная длина ключа чата")
|
||||
return plaintext, (kid or candidate_kid)
|
||||
raise ValueError("Не удалось расшифровать ключ чата")
|
||||
|
||||
|
||||
def extract_message_kid(value: str | None) -> str | None:
|
||||
token = str(value or "").strip()
|
||||
if not token:
|
||||
return None
|
||||
if token.startswith(_PREFIX_V2):
|
||||
if token.startswith(_PREFIX_V2) or token.startswith(_PREFIX_V3):
|
||||
parts = token.split(":", 3)
|
||||
if len(parts) != 4:
|
||||
return None
|
||||
|
|
@ -40,22 +115,54 @@ def extract_message_kid(value: str | None) -> str | None:
|
|||
|
||||
def is_encrypted_message(value: str | None) -> bool:
|
||||
token = str(value or "").strip()
|
||||
return token.startswith(_PREFIX_LEGACY) or token.startswith(_PREFIX_V2)
|
||||
return token.startswith(_PREFIX_LEGACY) or token.startswith(_PREFIX_V2) or token.startswith(_PREFIX_V3)
|
||||
|
||||
|
||||
def prepare_request_chat_crypto(extra_fields: dict[str, Any] | None) -> tuple[dict[str, Any], bytes, bool]:
|
||||
active_kid, key_map = get_chat_secrets()
|
||||
updated = dict(extra_fields or {})
|
||||
payload = _chat_payload_or_none(updated)
|
||||
chat_key: bytes | None = None
|
||||
payload_kid = active_kid
|
||||
changed = False
|
||||
|
||||
if payload:
|
||||
try:
|
||||
chat_key, payload_kid = _unwrap_chat_key(payload, key_map=key_map)
|
||||
except Exception:
|
||||
chat_key = None
|
||||
|
||||
if chat_key is None:
|
||||
chat_key = secrets.token_bytes(32)
|
||||
changed = True
|
||||
|
||||
if changed or payload_kid != active_kid or payload != _chat_payload_or_none(updated):
|
||||
active_secret = key_map.get(active_kid)
|
||||
if not active_secret:
|
||||
raise ValueError("Не найден активный ключ шифрования чата")
|
||||
updated[_CHAT_CRYPTO_EXTRA_FIELDS_KEY] = _wrap_chat_key(chat_key, kid=active_kid, secret=active_secret)
|
||||
changed = True
|
||||
|
||||
return updated, chat_key, changed
|
||||
|
||||
|
||||
def _request_chat_key(extra_fields: dict[str, Any] | None) -> tuple[bytes, str]:
|
||||
payload = _chat_payload_or_none(extra_fields)
|
||||
if not payload:
|
||||
raise ValueError("Не найден ключ шифрования чата для заявки")
|
||||
key_map = get_chat_secrets()[1]
|
||||
chat_key, payload_kid = _unwrap_chat_key(payload, key_map=key_map)
|
||||
return chat_key, payload_kid
|
||||
|
||||
|
||||
def encrypt_message_body(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
if not text:
|
||||
return text
|
||||
if is_encrypted_message(text):
|
||||
if not text or is_encrypted_message(text):
|
||||
return text
|
||||
|
||||
active_kid, key_map = get_chat_secrets()
|
||||
active_secret = key_map.get(active_kid)
|
||||
if not active_secret:
|
||||
raise ValueError("Не найден активный ключ шифрования чата")
|
||||
active_kid, active_secret = _active_chat_secret()
|
||||
key = key_digest(active_secret)
|
||||
|
||||
raw = text.encode("utf-8")
|
||||
|
|
@ -64,11 +171,37 @@ def encrypt_message_body(value: str | None) -> str | None:
|
|||
cipher = _xor_bytes(raw, stream)
|
||||
tag = hmac.new(key, _aad_v2(active_kid) + nonce + cipher, hashlib.sha256).digest()
|
||||
blob = nonce + tag + cipher
|
||||
return f"{_PREFIX_V2}{active_kid}:" + base64.urlsafe_b64encode(blob).decode("ascii")
|
||||
return f"{_PREFIX_V2}{active_kid}:" + _urlsafe_b64encode(blob)
|
||||
|
||||
|
||||
def encrypt_message_body_for_request(
|
||||
value: str | None,
|
||||
*,
|
||||
request_extra_fields: dict[str, Any] | None,
|
||||
) -> tuple[str | None, dict[str, Any], bool]:
|
||||
if value is None:
|
||||
return None, dict(request_extra_fields or {}), False
|
||||
text = str(value)
|
||||
if not text or is_encrypted_message(text):
|
||||
return text, dict(request_extra_fields or {}), False
|
||||
|
||||
updated_extra_fields, chat_key, changed = prepare_request_chat_crypto(request_extra_fields)
|
||||
kid = str(extract_request_chat_kek_kid(updated_extra_fields) or active_chat_kid())
|
||||
nonce = secrets.token_bytes(12)
|
||||
cipher = AESGCM(chat_key).encrypt(nonce, text.encode("utf-8"), _aad_v3_message(kid))
|
||||
return f"{_PREFIX_V3}{kid}:" + _urlsafe_b64encode(nonce + cipher), updated_extra_fields, changed
|
||||
|
||||
|
||||
def extract_request_chat_kek_kid(extra_fields: dict[str, Any] | None) -> str | None:
|
||||
payload = _chat_payload_or_none(extra_fields)
|
||||
if not payload:
|
||||
return None
|
||||
kid = str(payload.get("kek_kid") or "").strip()
|
||||
return kid or None
|
||||
|
||||
|
||||
def _decrypt_v2(encoded: str, *, kid: str, key: bytes) -> str:
|
||||
blob = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
||||
blob = _urlsafe_b64decode(encoded)
|
||||
if len(blob) < 16 + 32:
|
||||
raise ValueError("Некорректный зашифрованный формат сообщения")
|
||||
nonce = blob[:16]
|
||||
|
|
@ -82,8 +215,19 @@ def _decrypt_v2(encoded: str, *, kid: str, key: bytes) -> str:
|
|||
return raw.decode("utf-8")
|
||||
|
||||
|
||||
def _decrypt_v3(encoded: str, *, kid: str, request_extra_fields: dict[str, Any] | None) -> str:
|
||||
chat_key, _ = _request_chat_key(request_extra_fields)
|
||||
blob = _urlsafe_b64decode(encoded)
|
||||
if len(blob) <= 12:
|
||||
raise ValueError("Некорректный зашифрованный формат сообщения")
|
||||
nonce = blob[:12]
|
||||
cipher = blob[12:]
|
||||
raw = AESGCM(chat_key).decrypt(nonce, cipher, _aad_v3_message(kid))
|
||||
return raw.decode("utf-8")
|
||||
|
||||
|
||||
def _decrypt_legacy(encoded: str, keys: list[bytes]) -> str:
|
||||
blob = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
||||
blob = _urlsafe_b64decode(encoded)
|
||||
if len(blob) < 2 + 16 + 32:
|
||||
raise ValueError("Некорректный зашифрованный формат сообщения")
|
||||
version = blob[:2]
|
||||
|
|
@ -115,6 +259,8 @@ def decrypt_message_body(value: str | None) -> str | None:
|
|||
|
||||
active_kid, key_map = get_chat_secrets()
|
||||
_ = active_kid
|
||||
if text.startswith(_PREFIX_V3):
|
||||
raise ValueError("Для сообщений v3 требуется контекст заявки")
|
||||
if text.startswith(_PREFIX_V2):
|
||||
encoded = text[len(_PREFIX_V2) :]
|
||||
parts = encoded.split(":", 1)
|
||||
|
|
@ -132,3 +278,23 @@ def decrypt_message_body(value: str | None) -> str | None:
|
|||
|
||||
encoded = text[len(_PREFIX_LEGACY) :]
|
||||
return _decrypt_legacy(encoded, ordered_unique_key_digests(key_map.values()))
|
||||
|
||||
|
||||
def decrypt_message_body_for_request(
|
||||
value: str | None,
|
||||
*,
|
||||
request_extra_fields: dict[str, Any] | None,
|
||||
) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
if not text or not is_encrypted_message(text):
|
||||
return text
|
||||
if text.startswith(_PREFIX_V3):
|
||||
encoded = text[len(_PREFIX_V3) :]
|
||||
parts = encoded.split(":", 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError("Некорректный зашифрованный формат сообщения")
|
||||
kid, payload = str(parts[0] or "").strip(), parts[1]
|
||||
return _decrypt_v3(payload, kid=kid, request_extra_fields=request_extra_fields)
|
||||
return decrypt_message_body(text)
|
||||
|
|
|
|||
|
|
@ -1,24 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from time import perf_counter
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.message import Message
|
||||
from app.models.request import Request
|
||||
from app.models.request_data_requirement import RequestDataRequirement
|
||||
from app.services.chat_crypto import decrypt_message_body_for_request
|
||||
from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, notify_request_event
|
||||
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer
|
||||
|
||||
MAX_CHAT_MESSAGE_LEN = 12_000
|
||||
DEFAULT_CHAT_WINDOW_LIMIT = 50
|
||||
MAX_CHAT_WINDOW_LIMIT = 200
|
||||
MAX_CHAT_BODY_BATCH = 200
|
||||
CHAT_PARTICIPANT_ADMIN_IDS_KEY = "chat_participant_admin_ids"
|
||||
_CHAT_WORKSPACE_LOG = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
def _normalize_message_body(body: str | None) -> str:
|
||||
|
|
@ -49,19 +54,75 @@ def clamp_chat_window_limit(limit: int | None) -> int:
|
|||
return max(1, min(normalized, MAX_CHAT_WINDOW_LIMIT))
|
||||
|
||||
|
||||
def clamp_chat_body_batch_limit(limit: int | None) -> int:
|
||||
if limit is None:
|
||||
return MAX_CHAT_BODY_BATCH
|
||||
try:
|
||||
normalized = int(limit)
|
||||
except (TypeError, ValueError):
|
||||
normalized = MAX_CHAT_BODY_BATCH
|
||||
return max(1, min(normalized, MAX_CHAT_BODY_BATCH))
|
||||
|
||||
|
||||
def _parse_window_message_uuid(raw: str | None) -> uuid.UUID | None:
|
||||
value = str(raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_window_datetime(raw: str | None) -> datetime | None:
|
||||
value = str(raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
normalized = value.replace("Z", "+00:00")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def list_messages_for_request_window(
|
||||
db: Session,
|
||||
request_id: Any,
|
||||
*,
|
||||
limit: int | None,
|
||||
before_id: str | None = None,
|
||||
before_created_at: str | None = None,
|
||||
before_count: int = 0,
|
||||
) -> tuple[list[Message], int, bool, int]:
|
||||
) -> tuple[list[Message], bool]:
|
||||
window_limit = clamp_chat_window_limit(limit)
|
||||
loaded_count = max(0, int(before_count or 0))
|
||||
base_query = db.query(Message).filter(Message.request_id == request_id)
|
||||
before_uuid = _parse_window_message_uuid(before_id)
|
||||
before_dt = _parse_window_datetime(before_created_at)
|
||||
|
||||
if before_uuid is not None and before_dt is not None:
|
||||
base_query = base_query.filter(
|
||||
or_(
|
||||
Message.created_at < before_dt,
|
||||
and_(Message.created_at == before_dt, Message.id < before_uuid),
|
||||
)
|
||||
)
|
||||
rows_desc = (
|
||||
base_query
|
||||
.order_by(Message.created_at.desc(), Message.id.desc())
|
||||
.limit(window_limit + 1)
|
||||
.all()
|
||||
)
|
||||
has_more = len(rows_desc) > window_limit
|
||||
rows = list(reversed(rows_desc[:window_limit]))
|
||||
return rows, has_more
|
||||
|
||||
loaded_count = max(0, int(before_count or 0))
|
||||
total = int(base_query.count() or 0)
|
||||
if total <= 0 or loaded_count >= total:
|
||||
return [], total, False, loaded_count
|
||||
return [], False
|
||||
|
||||
remaining = total - loaded_count
|
||||
window_size = min(window_limit, remaining)
|
||||
|
|
@ -73,9 +134,8 @@ def list_messages_for_request_window(
|
|||
.limit(window_size)
|
||||
.all()
|
||||
)
|
||||
next_loaded_count = loaded_count + len(rows)
|
||||
has_more = offset > 0
|
||||
return rows, total, has_more, next_loaded_count
|
||||
return rows, has_more
|
||||
|
||||
|
||||
def _iso_or_none(value: datetime | None) -> str | None:
|
||||
|
|
@ -160,13 +220,14 @@ def mark_messages_read_for_staff(db: Session, *, request_id: Any, commit: bool =
|
|||
return _mark_counterparty_delivery(db, request_id=request_id, recipient="STAFF", mark_read=True, commit=commit)
|
||||
|
||||
|
||||
def serialize_message(row: Message) -> dict[str, Any]:
|
||||
def serialize_message(row: Message, *, body: str | None = None, body_loaded: bool = True) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"request_id": str(row.request_id),
|
||||
"author_type": row.author_type,
|
||||
"author_name": row.author_name,
|
||||
"body": row.body,
|
||||
"body": body,
|
||||
"body_loaded": bool(body_loaded),
|
||||
"message_kind": "TEXT",
|
||||
"request_data_items": [],
|
||||
"request_data_all_filled": False,
|
||||
|
|
@ -186,6 +247,30 @@ def _truncate_request_data_label(label: str, limit: int = 18) -> str:
|
|||
return text[: max(3, limit - 3)].rstrip() + "..."
|
||||
|
||||
|
||||
def _message_uuid_list(rows: list[Message]) -> list[uuid.UUID]:
|
||||
out: list[uuid.UUID] = []
|
||||
for row in rows:
|
||||
message_id = getattr(row, "id", None)
|
||||
if isinstance(message_id, uuid.UUID):
|
||||
out.append(message_id)
|
||||
return out
|
||||
|
||||
|
||||
def _request_data_message_ids(db: Session, request_id: Any, message_ids: list[uuid.UUID]) -> set[str]:
|
||||
if not message_ids:
|
||||
return set()
|
||||
rows = (
|
||||
db.query(RequestDataRequirement.request_message_id)
|
||||
.filter(
|
||||
RequestDataRequirement.request_id == request_id,
|
||||
RequestDataRequirement.request_message_id.in_(message_ids),
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
return {str(item[0]) for item in rows if item and item[0] is not None}
|
||||
|
||||
|
||||
def _normalize_admin_uuid(value: str | None) -> str | None:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
|
|
@ -218,13 +303,17 @@ def _register_chat_participant(request: Request, admin_user_id: str | None) -> N
|
|||
request.extra_fields = extra
|
||||
|
||||
|
||||
def serialize_messages_for_request(db: Session, request_id: Any, rows: list[Message]) -> list[dict[str, Any]]:
|
||||
message_ids = []
|
||||
for row in rows:
|
||||
try:
|
||||
message_ids.append(row.id)
|
||||
except Exception:
|
||||
continue
|
||||
def serialize_messages_for_request(
|
||||
db: Session,
|
||||
request_id: Any,
|
||||
rows: list[Message],
|
||||
*,
|
||||
request_extra_fields: dict[str, Any] | None = None,
|
||||
include_bodies: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
started_at = perf_counter()
|
||||
message_ids = _message_uuid_list(rows)
|
||||
requirements_started_at = perf_counter()
|
||||
requirements = (
|
||||
db.query(RequestDataRequirement)
|
||||
.filter(
|
||||
|
|
@ -241,6 +330,7 @@ def serialize_messages_for_request(db: Session, request_id: Any, rows: list[Mess
|
|||
if message_ids
|
||||
else []
|
||||
)
|
||||
requirements_ms = (perf_counter() - requirements_started_at) * 1000.0
|
||||
by_message_id: dict[str, list[RequestDataRequirement]] = {}
|
||||
for item in requirements:
|
||||
mid = str(item.request_message_id or "").strip()
|
||||
|
|
@ -260,13 +350,28 @@ def serialize_messages_for_request(db: Session, request_id: Any, rows: list[Mess
|
|||
continue
|
||||
attachment_map: dict[str, Attachment] = {}
|
||||
if file_attachment_ids:
|
||||
attachment_lookup_started_at = perf_counter()
|
||||
attachment_rows = db.query(Attachment).filter(Attachment.id.in_(file_attachment_ids)).all()
|
||||
attachment_map = {str(row.id): row for row in attachment_rows}
|
||||
attachment_lookup_ms = (perf_counter() - attachment_lookup_started_at) * 1000.0
|
||||
else:
|
||||
attachment_lookup_ms = 0.0
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
payload = serialize_message(row)
|
||||
linked = by_message_id.get(str(row.id), [])
|
||||
is_request_data = bool(linked)
|
||||
if is_request_data:
|
||||
body_value = "Запрос"
|
||||
body_loaded = True
|
||||
elif include_bodies:
|
||||
body_value = decrypt_message_body_for_request(row.body, request_extra_fields=request_extra_fields)
|
||||
body_loaded = True
|
||||
else:
|
||||
body_value = None
|
||||
body_loaded = False
|
||||
|
||||
payload = serialize_message(row, body=body_value, body_loaded=body_loaded)
|
||||
if linked:
|
||||
linked_sorted = sorted(
|
||||
linked,
|
||||
|
|
@ -315,9 +420,56 @@ def serialize_messages_for_request(db: Session, request_id: Any, rows: list[Mess
|
|||
else:
|
||||
payload["message_kind"] = "TEXT"
|
||||
out.append(payload)
|
||||
total_ms = (perf_counter() - started_at) * 1000.0
|
||||
_CHAT_WORKSPACE_LOG.info(
|
||||
"serialize_messages request_id=%s total_ms=%.2f requirements_ms=%.2f attachment_lookup_ms=%.2f rows=%s requirements=%s file_requirements=%s",
|
||||
str(request_id),
|
||||
total_ms,
|
||||
requirements_ms,
|
||||
attachment_lookup_ms,
|
||||
len(rows),
|
||||
len(requirements),
|
||||
len(file_attachment_ids),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def serialize_message_bodies_for_request(
|
||||
db: Session,
|
||||
request_id: Any,
|
||||
rows: list[Message],
|
||||
*,
|
||||
request_extra_fields: dict[str, Any] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
request_data_ids = _request_data_message_ids(db, request_id, _message_uuid_list(rows))
|
||||
payload: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
row_id = str(row.id)
|
||||
if row_id in request_data_ids:
|
||||
payload.append({"id": row_id, "body": "Запрос", "body_loaded": True})
|
||||
continue
|
||||
payload.append(
|
||||
{
|
||||
"id": row_id,
|
||||
"body": decrypt_message_body_for_request(row.body, request_extra_fields=request_extra_fields),
|
||||
"body_loaded": True,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def serialize_message_for_request(
|
||||
row: Message,
|
||||
*,
|
||||
request_extra_fields: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
return serialize_message(
|
||||
row,
|
||||
body=decrypt_message_body_for_request(row.body, request_extra_fields=request_extra_fields),
|
||||
body_loaded=True,
|
||||
)
|
||||
|
||||
|
||||
def create_client_message(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
|
|||
152
app/web/admin.js
152
app/web/admin.js
|
|
@ -5124,7 +5124,7 @@
|
|||
}
|
||||
} : void 0
|
||||
},
|
||||
String(entry.payload?.message_kind || "") === "REQUEST_DATA" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "chat-request-data-head" }, "\u0417\u0430\u043F\u0440\u043E\u0441"), renderRequestDataMessageItems(entry.payload)) : /* @__PURE__ */ React.createElement(React.Fragment, null, serviceMessageContent?.title ? /* @__PURE__ */ React.createElement("div", { className: "chat-service-head" }, serviceMessageContent.title) : null, serviceMessageContent ? serviceMessageContent.text ? /* @__PURE__ */ React.createElement("p", { className: "chat-message-text" }, serviceMessageContent.text) : null : /* @__PURE__ */ React.createElement("p", { className: "chat-message-text" }, String(entry.payload?.body || ""))),
|
||||
String(entry.payload?.message_kind || "") === "REQUEST_DATA" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "chat-request-data-head" }, "\u0417\u0430\u043F\u0440\u043E\u0441"), renderRequestDataMessageItems(entry.payload)) : /* @__PURE__ */ React.createElement(React.Fragment, null, serviceMessageContent?.title ? /* @__PURE__ */ React.createElement("div", { className: "chat-service-head" }, serviceMessageContent.title) : null, serviceMessageContent ? serviceMessageContent.text ? /* @__PURE__ */ React.createElement("p", { className: "chat-message-text" }, serviceMessageContent.text) : null : /* @__PURE__ */ React.createElement("p", { className: "chat-message-text" }, entry.payload?.body_loaded === false ? "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F..." : String(entry.payload?.body || ""))),
|
||||
(() => {
|
||||
if (String(entry.payload?.message_kind || "") === "REQUEST_DATA") return null;
|
||||
const messageId = String(entry.payload?.id || "").trim();
|
||||
|
|
@ -6131,6 +6131,18 @@
|
|||
});
|
||||
return sortRowsByCreatedAt(Array.from(merged.values()));
|
||||
}
|
||||
function getOldestMessageCursor(rows) {
|
||||
const sorted = sortRowsByCreatedAt(Array.isArray(rows) ? rows : []);
|
||||
const first = sorted[0];
|
||||
if (!first) return null;
|
||||
const beforeId = String(first.id || "").trim();
|
||||
const beforeCreatedAt = String(first.created_at || first.updated_at || "").trim();
|
||||
if (!beforeId || !beforeCreatedAt) return null;
|
||||
return { beforeId, beforeCreatedAt };
|
||||
}
|
||||
function collectDeferredMessageIds(rows) {
|
||||
return (Array.isArray(rows) ? rows : []).filter((row) => row && typeof row === "object" && row.body_loaded === false && String(row.id || "").trim()).map((row) => String(row.id).trim());
|
||||
}
|
||||
function normalizeMessageAuthors(rows, users) {
|
||||
const usersByEmail = new Map(
|
||||
(Array.isArray(users) ? users : []).filter((user) => user && user.email).map((user) => [String(user.email).toLowerCase(), String(user.name || user.email)])
|
||||
|
|
@ -6146,6 +6158,30 @@
|
|||
return item;
|
||||
});
|
||||
}
|
||||
function buildFinanceSummaryFromInvoices(financeSummaryData, rowData, invoices) {
|
||||
if (financeSummaryData && typeof financeSummaryData === "object") return financeSummaryData;
|
||||
const paidInvoices = (Array.isArray(invoices) ? invoices : []).filter(
|
||||
(item) => String(item?.status || "").toUpperCase() === "PAID"
|
||||
);
|
||||
const paidTotal = paidInvoices.reduce((acc, item) => {
|
||||
const amount = Number(item?.amount || 0);
|
||||
return Number.isFinite(amount) ? acc + amount : acc;
|
||||
}, 0);
|
||||
const latestPaidAt = paidInvoices.reduce((latest, item) => {
|
||||
const raw = item?.paid_at;
|
||||
const ts = raw ? new Date(raw).getTime() : Number.NaN;
|
||||
if (!Number.isFinite(ts)) return latest;
|
||||
if (!latest) return String(raw);
|
||||
const latestTs = new Date(latest).getTime();
|
||||
return ts > latestTs ? String(raw) : latest;
|
||||
}, "");
|
||||
return {
|
||||
request_cost: rowData?.request_cost ?? null,
|
||||
effective_rate: rowData?.effective_rate ?? null,
|
||||
paid_total: Math.round((paidTotal + Number.EPSILON) * 100) / 100,
|
||||
last_paid_at: latestPaidAt || rowData?.paid_at || null
|
||||
};
|
||||
}
|
||||
function useRequestWorkspace(options) {
|
||||
const { useCallback, useRef, useState } = React;
|
||||
const opts = options || {};
|
||||
|
|
@ -6161,6 +6197,32 @@
|
|||
setRequestModal(createRequestModalState());
|
||||
requestOpenGuardRef.current = { requestId: "", ts: 0 };
|
||||
}, []);
|
||||
const hydrateRequestMessageBodies = useCallback(
|
||||
async (requestId, rows) => {
|
||||
const targetRequestId = String(requestId || "").trim();
|
||||
const ids = collectDeferredMessageIds(rows);
|
||||
if (!api || !targetRequestId || !ids.length) return null;
|
||||
try {
|
||||
const payload = await api("/api/admin/chat/requests/" + targetRequestId + "/message-bodies", {
|
||||
method: "POST",
|
||||
body: { ids }
|
||||
});
|
||||
const nextRows = Array.isArray(payload?.rows) ? payload.rows : [];
|
||||
if (!nextRows.length) return payload || null;
|
||||
setRequestModal((prev) => {
|
||||
if (String(prev.requestId || "") !== targetRequestId) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: mergeRowsById(prev.messages, nextRows)
|
||||
};
|
||||
});
|
||||
return payload || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[api]
|
||||
);
|
||||
const updateRequestModalMessageDraft = useCallback((event) => {
|
||||
const value = event.target.value;
|
||||
setRequestModal((prev) => ({ ...prev, messageDraft: value }));
|
||||
|
|
@ -6275,12 +6337,10 @@
|
|||
}));
|
||||
}
|
||||
try {
|
||||
const workspaceData = await api("/api/admin/requests/" + requestId + "/workspace");
|
||||
const workspaceData = await api("/api/admin/requests/" + requestId + "/workspace?include_related=false");
|
||||
const row = workspaceData?.request || null;
|
||||
const messagesData = { rows: workspaceData?.messages || [] };
|
||||
const attachmentsData = { rows: workspaceData?.attachments || [] };
|
||||
const statusRouteData = workspaceData?.status_route || { nodes: [] };
|
||||
const invoicesData = { rows: workspaceData?.invoices || [] };
|
||||
const financeSummaryData = workspaceData?.finance_summary || null;
|
||||
const usersById = new Map(users.filter((user) => user && user.id).map((user) => [String(user.id), user]));
|
||||
const rowData = row && typeof row === "object" ? { ...row } : row;
|
||||
|
|
@ -6294,40 +6354,15 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
const attachments = (attachmentsData.rows || []).map((item) => ({
|
||||
...item,
|
||||
download_url: resolveAdminObjectSrc2(item.s3_key, token)
|
||||
}));
|
||||
const normalizedMessages = normalizeMessageAuthors(messagesData.rows || [], users);
|
||||
const invoices = Array.isArray(invoicesData?.rows) ? invoicesData.rows : [];
|
||||
const paidInvoices = invoices.filter(
|
||||
(item) => String(item?.status || "").toUpperCase() === "PAID"
|
||||
);
|
||||
const paidTotal = paidInvoices.reduce((acc, item) => {
|
||||
const amount = Number(item?.amount || 0);
|
||||
return Number.isFinite(amount) ? acc + amount : acc;
|
||||
}, 0);
|
||||
const latestPaidAt = paidInvoices.reduce((latest, item) => {
|
||||
const raw = item?.paid_at;
|
||||
const ts = raw ? new Date(raw).getTime() : Number.NaN;
|
||||
if (!Number.isFinite(ts)) return latest;
|
||||
if (!latest) return String(raw);
|
||||
const latestTs = new Date(latest).getTime();
|
||||
return ts > latestTs ? String(raw) : latest;
|
||||
}, "");
|
||||
setRequestModal((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
requestId: rowData?.id || requestId,
|
||||
trackNumber: String(rowData?.track_number || ""),
|
||||
requestData: rowData,
|
||||
financeSummary: financeSummaryData || {
|
||||
request_cost: rowData?.request_cost ?? null,
|
||||
effective_rate: rowData?.effective_rate ?? null,
|
||||
paid_total: Math.round((paidTotal + Number.EPSILON) * 100) / 100,
|
||||
last_paid_at: latestPaidAt || rowData?.paid_at || null
|
||||
},
|
||||
invoices,
|
||||
financeSummary: buildFinanceSummaryFromInvoices(financeSummaryData, rowData, []),
|
||||
invoices: [],
|
||||
statusRouteNodes: Array.isArray(statusRouteData?.nodes) ? statusRouteData.nodes : [],
|
||||
statusHistory: Array.isArray(statusRouteData?.history) ? statusRouteData.history : [],
|
||||
availableStatuses: Array.isArray(statusRouteData?.available_statuses) ? statusRouteData.available_statuses : [],
|
||||
|
|
@ -6337,10 +6372,35 @@
|
|||
messagesLoadingMore: false,
|
||||
messagesLoadedCount: Number(workspaceData?.messages_loaded_count || normalizedMessages.length || 0),
|
||||
messagesTotal: Number(workspaceData?.messages_total || normalizedMessages.length || 0),
|
||||
attachments,
|
||||
attachments: [],
|
||||
selectedFiles: [],
|
||||
fileUploading: false
|
||||
}));
|
||||
void hydrateRequestMessageBodies(requestId, normalizedMessages);
|
||||
void Promise.all([
|
||||
api("/api/admin/uploads/request-attachments/" + requestId),
|
||||
api("/api/admin/invoices/by-request/" + requestId),
|
||||
api("/api/admin/requests/" + requestId + "/status-route")
|
||||
]).then(([attachmentsData, invoicesData, nextStatusRouteData]) => {
|
||||
const attachments = (attachmentsData?.rows || []).map((item) => ({
|
||||
...item,
|
||||
download_url: resolveAdminObjectSrc2(item.s3_key, token)
|
||||
}));
|
||||
const invoices = Array.isArray(invoicesData?.rows) ? invoicesData.rows : [];
|
||||
setRequestModal((prev) => {
|
||||
if (String(prev.requestId || "") !== String(requestId)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
attachments,
|
||||
invoices,
|
||||
financeSummary: buildFinanceSummaryFromInvoices(prev.financeSummary, prev.requestData, invoices),
|
||||
statusRouteNodes: Array.isArray(nextStatusRouteData?.nodes) ? nextStatusRouteData.nodes : [],
|
||||
statusHistory: Array.isArray(nextStatusRouteData?.history) ? nextStatusRouteData.history : [],
|
||||
availableStatuses: Array.isArray(nextStatusRouteData?.available_statuses) ? nextStatusRouteData.available_statuses : [],
|
||||
currentImportantDateAt: String(nextStatusRouteData?.current_important_date_at || prev.currentImportantDateAt || "")
|
||||
};
|
||||
});
|
||||
}).catch(() => null);
|
||||
if (showLoading && typeof setStatus === "function") setStatus("requestModal", "", "");
|
||||
} catch (error) {
|
||||
setRequestModal((prev) => ({
|
||||
|
|
@ -6366,7 +6426,7 @@
|
|||
if (typeof setStatus === "function") setStatus("requestModal", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error");
|
||||
}
|
||||
},
|
||||
[api, resolveAdminObjectSrc2, setStatus, token, users]
|
||||
[api, hydrateRequestMessageBodies, resolveAdminObjectSrc2, setStatus, token, users]
|
||||
);
|
||||
const refreshRequestModal = useCallback(async () => {
|
||||
if (!requestModal.requestId) return;
|
||||
|
|
@ -6527,22 +6587,33 @@
|
|||
);
|
||||
const loadOlderRequestMessages = useCallback(async () => {
|
||||
const requestId = String(requestModal.requestId || "").trim();
|
||||
const loadedCount = Number(requestModal.messagesLoadedCount || 0);
|
||||
const cursor = getOldestMessageCursor(requestModal.messages);
|
||||
if (!api || !requestId || requestModal.messagesLoadingMore || !requestModal.messagesHasMore) return null;
|
||||
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: true }));
|
||||
try {
|
||||
const payload = await api(
|
||||
"/api/admin/chat/requests/" + requestId + "/messages-window?before_count=" + encodeURIComponent(String(loadedCount))
|
||||
);
|
||||
const query = new URLSearchParams({ include_body: "false" });
|
||||
if (cursor?.beforeId && cursor?.beforeCreatedAt) {
|
||||
query.set("before_id", cursor.beforeId);
|
||||
query.set("before_created_at", cursor.beforeCreatedAt);
|
||||
} else {
|
||||
query.set("before_count", String(Number(requestModal.messagesLoadedCount || 0)));
|
||||
}
|
||||
const payload = await api("/api/admin/chat/requests/" + requestId + "/messages-window?" + query.toString());
|
||||
const nextMessages = normalizeMessageAuthors(payload?.rows || [], users);
|
||||
setRequestModal((prev) => ({
|
||||
...prev,
|
||||
messagesLoadingMore: false,
|
||||
messages: mergeRowsById(nextMessages, prev.messages),
|
||||
...(function() {
|
||||
const merged = mergeRowsById(nextMessages, prev.messages);
|
||||
return {
|
||||
messages: merged,
|
||||
messagesHasMore: Boolean(payload?.has_more),
|
||||
messagesLoadedCount: Number(payload?.loaded_count || prev.messagesLoadedCount || 0),
|
||||
messagesTotal: Number(payload?.total || prev.messagesTotal || 0)
|
||||
messagesLoadedCount: merged.length,
|
||||
messagesTotal: Number(prev.messagesTotal || merged.length || 0)
|
||||
};
|
||||
})()
|
||||
}));
|
||||
void hydrateRequestMessageBodies(requestId, nextMessages);
|
||||
return payload || null;
|
||||
} catch (error) {
|
||||
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: false }));
|
||||
|
|
@ -6556,6 +6627,7 @@
|
|||
requestModal.messagesLoadingMore,
|
||||
requestModal.requestId,
|
||||
setStatus,
|
||||
hydrateRequestMessageBodies,
|
||||
users
|
||||
]);
|
||||
const setRequestTyping = useCallback(
|
||||
|
|
|
|||
|
|
@ -1761,7 +1761,9 @@ export function RequestWorkspace({
|
|||
{serviceMessageContent ? (
|
||||
serviceMessageContent.text ? <p className="chat-message-text">{serviceMessageContent.text}</p> : null
|
||||
) : (
|
||||
<p className="chat-message-text">{String(entry.payload?.body || "")}</p>
|
||||
<p className="chat-message-text">
|
||||
{entry.payload?.body_loaded === false ? "Загрузка сообщения..." : String(entry.payload?.body || "")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,22 @@ function mergeRowsById(existingRows, incomingRows) {
|
|||
return sortRowsByCreatedAt(Array.from(merged.values()));
|
||||
}
|
||||
|
||||
function getOldestMessageCursor(rows) {
|
||||
const sorted = sortRowsByCreatedAt(Array.isArray(rows) ? rows : []);
|
||||
const first = sorted[0];
|
||||
if (!first) return null;
|
||||
const beforeId = String(first.id || "").trim();
|
||||
const beforeCreatedAt = String(first.created_at || first.updated_at || "").trim();
|
||||
if (!beforeId || !beforeCreatedAt) return null;
|
||||
return { beforeId, beforeCreatedAt };
|
||||
}
|
||||
|
||||
function collectDeferredMessageIds(rows) {
|
||||
return (Array.isArray(rows) ? rows : [])
|
||||
.filter((row) => row && typeof row === "object" && row.body_loaded === false && String(row.id || "").trim())
|
||||
.map((row) => String(row.id).trim());
|
||||
}
|
||||
|
||||
function normalizeMessageAuthors(rows, users) {
|
||||
const usersByEmail = new Map(
|
||||
(Array.isArray(users) ? users : [])
|
||||
|
|
@ -95,6 +111,31 @@ function normalizeMessageAuthors(rows, users) {
|
|||
});
|
||||
}
|
||||
|
||||
function buildFinanceSummaryFromInvoices(financeSummaryData, rowData, invoices) {
|
||||
if (financeSummaryData && typeof financeSummaryData === "object") return financeSummaryData;
|
||||
const paidInvoices = (Array.isArray(invoices) ? invoices : []).filter(
|
||||
(item) => String(item?.status || "").toUpperCase() === "PAID"
|
||||
);
|
||||
const paidTotal = paidInvoices.reduce((acc, item) => {
|
||||
const amount = Number(item?.amount || 0);
|
||||
return Number.isFinite(amount) ? acc + amount : acc;
|
||||
}, 0);
|
||||
const latestPaidAt = paidInvoices.reduce((latest, item) => {
|
||||
const raw = item?.paid_at;
|
||||
const ts = raw ? new Date(raw).getTime() : Number.NaN;
|
||||
if (!Number.isFinite(ts)) return latest;
|
||||
if (!latest) return String(raw);
|
||||
const latestTs = new Date(latest).getTime();
|
||||
return ts > latestTs ? String(raw) : latest;
|
||||
}, "");
|
||||
return {
|
||||
request_cost: rowData?.request_cost ?? null,
|
||||
effective_rate: rowData?.effective_rate ?? null,
|
||||
paid_total: Math.round((paidTotal + Number.EPSILON) * 100) / 100,
|
||||
last_paid_at: latestPaidAt || rowData?.paid_at || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRequestWorkspace(options) {
|
||||
const { useCallback, useRef, useState } = React;
|
||||
const opts = options || {};
|
||||
|
|
@ -113,6 +154,33 @@ export function useRequestWorkspace(options) {
|
|||
requestOpenGuardRef.current = { requestId: "", ts: 0 };
|
||||
}, []);
|
||||
|
||||
const hydrateRequestMessageBodies = useCallback(
|
||||
async (requestId, rows) => {
|
||||
const targetRequestId = String(requestId || "").trim();
|
||||
const ids = collectDeferredMessageIds(rows);
|
||||
if (!api || !targetRequestId || !ids.length) return null;
|
||||
try {
|
||||
const payload = await api("/api/admin/chat/requests/" + targetRequestId + "/message-bodies", {
|
||||
method: "POST",
|
||||
body: { ids },
|
||||
});
|
||||
const nextRows = Array.isArray(payload?.rows) ? payload.rows : [];
|
||||
if (!nextRows.length) return payload || null;
|
||||
setRequestModal((prev) => {
|
||||
if (String(prev.requestId || "") !== targetRequestId) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: mergeRowsById(prev.messages, nextRows),
|
||||
};
|
||||
});
|
||||
return payload || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[api]
|
||||
);
|
||||
|
||||
const updateRequestModalMessageDraft = useCallback((event) => {
|
||||
const value = event.target.value;
|
||||
setRequestModal((prev) => ({ ...prev, messageDraft: value }));
|
||||
|
|
@ -240,12 +308,10 @@ export function useRequestWorkspace(options) {
|
|||
}
|
||||
|
||||
try {
|
||||
const workspaceData = await api("/api/admin/requests/" + requestId + "/workspace");
|
||||
const workspaceData = await api("/api/admin/requests/" + requestId + "/workspace?include_related=false");
|
||||
const row = workspaceData?.request || null;
|
||||
const messagesData = { rows: workspaceData?.messages || [] };
|
||||
const attachmentsData = { rows: workspaceData?.attachments || [] };
|
||||
const statusRouteData = workspaceData?.status_route || { nodes: [] };
|
||||
const invoicesData = { rows: workspaceData?.invoices || [] };
|
||||
const financeSummaryData = workspaceData?.finance_summary || null;
|
||||
const usersById = new Map(users.filter((user) => user && user.id).map((user) => [String(user.id), user]));
|
||||
const rowData = row && typeof row === "object" ? { ...row } : row;
|
||||
|
|
@ -259,40 +325,15 @@ export function useRequestWorkspace(options) {
|
|||
}
|
||||
}
|
||||
}
|
||||
const attachments = (attachmentsData.rows || []).map((item) => ({
|
||||
...item,
|
||||
download_url: resolveAdminObjectSrc(item.s3_key, token),
|
||||
}));
|
||||
const normalizedMessages = normalizeMessageAuthors(messagesData.rows || [], users);
|
||||
const invoices = Array.isArray(invoicesData?.rows) ? invoicesData.rows : [];
|
||||
const paidInvoices = invoices.filter(
|
||||
(item) => String(item?.status || "").toUpperCase() === "PAID"
|
||||
);
|
||||
const paidTotal = paidInvoices.reduce((acc, item) => {
|
||||
const amount = Number(item?.amount || 0);
|
||||
return Number.isFinite(amount) ? acc + amount : acc;
|
||||
}, 0);
|
||||
const latestPaidAt = paidInvoices.reduce((latest, item) => {
|
||||
const raw = item?.paid_at;
|
||||
const ts = raw ? new Date(raw).getTime() : Number.NaN;
|
||||
if (!Number.isFinite(ts)) return latest;
|
||||
if (!latest) return String(raw);
|
||||
const latestTs = new Date(latest).getTime();
|
||||
return ts > latestTs ? String(raw) : latest;
|
||||
}, "");
|
||||
setRequestModal((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
requestId: rowData?.id || requestId,
|
||||
trackNumber: String(rowData?.track_number || ""),
|
||||
requestData: rowData,
|
||||
financeSummary: financeSummaryData || {
|
||||
request_cost: rowData?.request_cost ?? null,
|
||||
effective_rate: rowData?.effective_rate ?? null,
|
||||
paid_total: Math.round((paidTotal + Number.EPSILON) * 100) / 100,
|
||||
last_paid_at: latestPaidAt || rowData?.paid_at || null,
|
||||
},
|
||||
invoices,
|
||||
financeSummary: buildFinanceSummaryFromInvoices(financeSummaryData, rowData, []),
|
||||
invoices: [],
|
||||
statusRouteNodes: Array.isArray(statusRouteData?.nodes) ? statusRouteData.nodes : [],
|
||||
statusHistory: Array.isArray(statusRouteData?.history) ? statusRouteData.history : [],
|
||||
availableStatuses: Array.isArray(statusRouteData?.available_statuses) ? statusRouteData.available_statuses : [],
|
||||
|
|
@ -302,10 +343,37 @@ export function useRequestWorkspace(options) {
|
|||
messagesLoadingMore: false,
|
||||
messagesLoadedCount: Number(workspaceData?.messages_loaded_count || normalizedMessages.length || 0),
|
||||
messagesTotal: Number(workspaceData?.messages_total || normalizedMessages.length || 0),
|
||||
attachments,
|
||||
attachments: [],
|
||||
selectedFiles: [],
|
||||
fileUploading: false,
|
||||
}));
|
||||
void hydrateRequestMessageBodies(requestId, normalizedMessages);
|
||||
void Promise.all([
|
||||
api("/api/admin/uploads/request-attachments/" + requestId),
|
||||
api("/api/admin/invoices/by-request/" + requestId),
|
||||
api("/api/admin/requests/" + requestId + "/status-route"),
|
||||
])
|
||||
.then(([attachmentsData, invoicesData, nextStatusRouteData]) => {
|
||||
const attachments = (attachmentsData?.rows || []).map((item) => ({
|
||||
...item,
|
||||
download_url: resolveAdminObjectSrc(item.s3_key, token),
|
||||
}));
|
||||
const invoices = Array.isArray(invoicesData?.rows) ? invoicesData.rows : [];
|
||||
setRequestModal((prev) => {
|
||||
if (String(prev.requestId || "") !== String(requestId)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
attachments,
|
||||
invoices,
|
||||
financeSummary: buildFinanceSummaryFromInvoices(prev.financeSummary, prev.requestData, invoices),
|
||||
statusRouteNodes: Array.isArray(nextStatusRouteData?.nodes) ? nextStatusRouteData.nodes : [],
|
||||
statusHistory: Array.isArray(nextStatusRouteData?.history) ? nextStatusRouteData.history : [],
|
||||
availableStatuses: Array.isArray(nextStatusRouteData?.available_statuses) ? nextStatusRouteData.available_statuses : [],
|
||||
currentImportantDateAt: String(nextStatusRouteData?.current_important_date_at || prev.currentImportantDateAt || ""),
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => null);
|
||||
if (showLoading && typeof setStatus === "function") setStatus("requestModal", "", "");
|
||||
} catch (error) {
|
||||
setRequestModal((prev) => ({
|
||||
|
|
@ -331,7 +399,7 @@ export function useRequestWorkspace(options) {
|
|||
if (typeof setStatus === "function") setStatus("requestModal", "Ошибка: " + error.message, "error");
|
||||
}
|
||||
},
|
||||
[api, resolveAdminObjectSrc, setStatus, token, users]
|
||||
[api, hydrateRequestMessageBodies, resolveAdminObjectSrc, setStatus, token, users]
|
||||
);
|
||||
|
||||
const refreshRequestModal = useCallback(async () => {
|
||||
|
|
@ -509,25 +577,33 @@ export function useRequestWorkspace(options) {
|
|||
|
||||
const loadOlderRequestMessages = useCallback(async () => {
|
||||
const requestId = String(requestModal.requestId || "").trim();
|
||||
const loadedCount = Number(requestModal.messagesLoadedCount || 0);
|
||||
const cursor = getOldestMessageCursor(requestModal.messages);
|
||||
if (!api || !requestId || requestModal.messagesLoadingMore || !requestModal.messagesHasMore) return null;
|
||||
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: true }));
|
||||
try {
|
||||
const payload = await api(
|
||||
"/api/admin/chat/requests/" +
|
||||
requestId +
|
||||
"/messages-window?before_count=" +
|
||||
encodeURIComponent(String(loadedCount))
|
||||
);
|
||||
const query = new URLSearchParams({ include_body: "false" });
|
||||
if (cursor?.beforeId && cursor?.beforeCreatedAt) {
|
||||
query.set("before_id", cursor.beforeId);
|
||||
query.set("before_created_at", cursor.beforeCreatedAt);
|
||||
} else {
|
||||
query.set("before_count", String(Number(requestModal.messagesLoadedCount || 0)));
|
||||
}
|
||||
const payload = await api("/api/admin/chat/requests/" + requestId + "/messages-window?" + query.toString());
|
||||
const nextMessages = normalizeMessageAuthors(payload?.rows || [], users);
|
||||
setRequestModal((prev) => ({
|
||||
...prev,
|
||||
messagesLoadingMore: false,
|
||||
messages: mergeRowsById(nextMessages, prev.messages),
|
||||
...(function () {
|
||||
const merged = mergeRowsById(nextMessages, prev.messages);
|
||||
return {
|
||||
messages: merged,
|
||||
messagesHasMore: Boolean(payload?.has_more),
|
||||
messagesLoadedCount: Number(payload?.loaded_count || prev.messagesLoadedCount || 0),
|
||||
messagesTotal: Number(payload?.total || prev.messagesTotal || 0),
|
||||
messagesLoadedCount: merged.length,
|
||||
messagesTotal: Number(prev.messagesTotal || merged.length || 0),
|
||||
};
|
||||
})(),
|
||||
}));
|
||||
void hydrateRequestMessageBodies(requestId, nextMessages);
|
||||
return payload || null;
|
||||
} catch (error) {
|
||||
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: false }));
|
||||
|
|
@ -541,6 +617,7 @@ export function useRequestWorkspace(options) {
|
|||
requestModal.messagesLoadingMore,
|
||||
requestModal.requestId,
|
||||
setStatus,
|
||||
hydrateRequestMessageBodies,
|
||||
users,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -199,6 +199,8 @@
|
|||
currentImportantDateAt,
|
||||
pendingStatusChangePreset,
|
||||
messages,
|
||||
messagesHasMore,
|
||||
messagesLoadingMore,
|
||||
attachments,
|
||||
messageDraft,
|
||||
selectedFiles,
|
||||
|
|
@ -206,6 +208,7 @@
|
|||
status,
|
||||
onMessageChange,
|
||||
onSendMessage,
|
||||
onLoadOlderMessages,
|
||||
onFilesSelect,
|
||||
onRemoveSelectedFile,
|
||||
onClearSelectedFiles,
|
||||
|
|
@ -1571,7 +1574,16 @@
|
|||
disabled: loading || fileUploading,
|
||||
style: { position: "absolute", width: "1px", height: "1px", opacity: 0, pointerEvents: "none" }
|
||||
}
|
||||
), chatTab === "chat" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("ul", { className: "simple-list request-modal-list request-chat-list", id: idMap.messagesList, ref: chatListRef }, chatTimelineItems.length ? chatTimelineItems.map(
|
||||
), chatTab === "chat" ? /* @__PURE__ */ React.createElement(React.Fragment, null, messagesHasMore ? /* @__PURE__ */ React.createElement("div", { className: "request-chat-history-actions" }, /* @__PURE__ */ React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "btn secondary",
|
||||
onClick: onLoadOlderMessages,
|
||||
disabled: loading || fileUploading || messagesLoadingMore
|
||||
},
|
||||
messagesLoadingMore ? "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u0438\u0441\u0442\u043E\u0440\u0438\u0438..." : "\u041F\u043E\u043A\u0430\u0437\u0430\u0442\u044C \u043F\u0440\u0435\u0434\u044B\u0434\u0443\u0449\u0438\u0435 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F"
|
||||
)) : null, /* @__PURE__ */ React.createElement("ul", { className: "simple-list request-modal-list request-chat-list", id: idMap.messagesList, ref: chatListRef }, chatTimelineItems.length ? chatTimelineItems.map(
|
||||
(entry) => entry.type === "date" ? /* @__PURE__ */ React.createElement("li", { key: entry.key, className: "chat-date-divider" }, /* @__PURE__ */ React.createElement("span", null, entry.label)) : entry.type === "file" ? /* @__PURE__ */ React.createElement(
|
||||
"li",
|
||||
{
|
||||
|
|
@ -1612,7 +1624,7 @@
|
|||
}
|
||||
} : void 0
|
||||
},
|
||||
String(entry.payload?.message_kind || "") === "REQUEST_DATA" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "chat-request-data-head" }, "\u0417\u0430\u043F\u0440\u043E\u0441"), renderRequestDataMessageItems(entry.payload)) : /* @__PURE__ */ React.createElement(React.Fragment, null, serviceMessageContent?.title ? /* @__PURE__ */ React.createElement("div", { className: "chat-service-head" }, serviceMessageContent.title) : null, serviceMessageContent ? serviceMessageContent.text ? /* @__PURE__ */ React.createElement("p", { className: "chat-message-text" }, serviceMessageContent.text) : null : /* @__PURE__ */ React.createElement("p", { className: "chat-message-text" }, String(entry.payload?.body || ""))),
|
||||
String(entry.payload?.message_kind || "") === "REQUEST_DATA" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "chat-request-data-head" }, "\u0417\u0430\u043F\u0440\u043E\u0441"), renderRequestDataMessageItems(entry.payload)) : /* @__PURE__ */ React.createElement(React.Fragment, null, serviceMessageContent?.title ? /* @__PURE__ */ React.createElement("div", { className: "chat-service-head" }, serviceMessageContent.title) : null, serviceMessageContent ? serviceMessageContent.text ? /* @__PURE__ */ React.createElement("p", { className: "chat-message-text" }, serviceMessageContent.text) : null : /* @__PURE__ */ React.createElement("p", { className: "chat-message-text" }, entry.payload?.body_loaded === false ? "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F..." : String(entry.payload?.body || ""))),
|
||||
(() => {
|
||||
if (String(entry.payload?.message_kind || "") === "REQUEST_DATA") return null;
|
||||
const messageId = String(entry.payload?.id || "").trim();
|
||||
|
|
@ -2237,6 +2249,10 @@
|
|||
currentImportantDateAt: "",
|
||||
pendingStatusChangePreset: null,
|
||||
messages: [],
|
||||
messagesHasMore: false,
|
||||
messagesLoadingMore: false,
|
||||
messagesLoadedCount: 0,
|
||||
messagesTotal: 0,
|
||||
attachments: [],
|
||||
messageDraft: "",
|
||||
selectedFiles: [],
|
||||
|
|
@ -2267,6 +2283,18 @@
|
|||
});
|
||||
return sortRowsByCreatedAt(Array.from(merged.values()));
|
||||
}
|
||||
function getOldestMessageCursor(rows) {
|
||||
const sorted = sortRowsByCreatedAt(Array.isArray(rows) ? rows : []);
|
||||
const first = sorted[0];
|
||||
if (!first) return null;
|
||||
const beforeId = String(first.id || "").trim();
|
||||
const beforeCreatedAt = String(first.created_at || first.updated_at || "").trim();
|
||||
if (!beforeId || !beforeCreatedAt) return null;
|
||||
return { beforeId, beforeCreatedAt };
|
||||
}
|
||||
function collectDeferredMessageIds(rows) {
|
||||
return (Array.isArray(rows) ? rows : []).filter((row) => row && typeof row === "object" && row.body_loaded === false && String(row.id || "").trim()).map((row) => String(row.id).trim());
|
||||
}
|
||||
function StatusLine({ status }) {
|
||||
return /* @__PURE__ */ React.createElement("p", { className: "status" + (status?.kind ? " " + status.kind : "") }, status?.message || "");
|
||||
}
|
||||
|
|
@ -2701,6 +2729,37 @@
|
|||
});
|
||||
return completeData;
|
||||
}, [apiJson, buildStorageUploadError, requestModal.requestId, runUploadStepWithRetry]);
|
||||
const hydratePublicMessageBodies = useCallback(
|
||||
async (trackNumber, rows) => {
|
||||
const track = String(trackNumber || "").trim().toUpperCase();
|
||||
const ids = collectDeferredMessageIds(rows);
|
||||
if (!track || !ids.length) return null;
|
||||
try {
|
||||
const payload = await apiJson(
|
||||
"/api/public/chat/requests/" + encodeURIComponent(track) + "/message-bodies",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids })
|
||||
},
|
||||
"\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0442\u0435\u043A\u0441\u0442\u044B \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0439"
|
||||
);
|
||||
const nextRows = Array.isArray(payload?.rows) ? payload.rows : [];
|
||||
if (!nextRows.length) return payload || null;
|
||||
setRequestModal((prev) => {
|
||||
if (String(prev.trackNumber || "").trim().toUpperCase() !== track) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: mergeRowsById(prev.messages, nextRows)
|
||||
};
|
||||
});
|
||||
return payload || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[apiJson]
|
||||
);
|
||||
const loadRequestWorkspace = useCallback(
|
||||
async (trackNumber, showLoading) => {
|
||||
const track = String(trackNumber || "").trim().toUpperCase();
|
||||
|
|
@ -2710,7 +2769,11 @@
|
|||
}
|
||||
const [requestData, messagesData, attachmentsData, invoicesData, statusRouteData, serviceRequestsData] = await Promise.all([
|
||||
apiJson("/api/public/requests/" + encodeURIComponent(track), null, "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u0442\u043A\u0440\u044B\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443"),
|
||||
apiJson("/api/public/chat/requests/" + encodeURIComponent(track) + "/messages", null, "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F"),
|
||||
apiJson(
|
||||
"/api/public/chat/requests/" + encodeURIComponent(track) + "/messages-window?include_body=false",
|
||||
null,
|
||||
"\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F"
|
||||
),
|
||||
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/attachments", null, "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0444\u0430\u0439\u043B\u044B"),
|
||||
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/invoices", null, "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0441\u0447\u0435\u0442\u0430"),
|
||||
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/status-route", null, "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u043C\u0430\u0440\u0448\u0440\u0443\u0442 \u0441\u0442\u0430\u0442\u0443\u0441\u043E\u0432"),
|
||||
|
|
@ -2749,13 +2812,67 @@
|
|||
availableStatuses: [],
|
||||
currentImportantDateAt: String(statusRouteData?.current_important_date_at || requestData?.important_date_at || ""),
|
||||
invoices,
|
||||
messages: Array.isArray(messagesData) ? messagesData : [],
|
||||
messages: Array.isArray(messagesData?.rows) ? messagesData.rows : [],
|
||||
messagesHasMore: Boolean(messagesData?.has_more),
|
||||
messagesLoadingMore: false,
|
||||
messagesLoadedCount: Array.isArray(messagesData?.rows) ? messagesData.rows.length : 0,
|
||||
messagesTotal: Number(messagesData?.total || (Array.isArray(messagesData?.rows) ? messagesData.rows.length : 0)),
|
||||
attachments: Array.isArray(attachmentsData) ? attachmentsData : [],
|
||||
fileUploading: false
|
||||
}));
|
||||
void hydratePublicMessageBodies(track, Array.isArray(messagesData?.rows) ? messagesData.rows : []);
|
||||
},
|
||||
[apiJson]
|
||||
[apiJson, hydratePublicMessageBodies]
|
||||
);
|
||||
const loadOlderPublicMessages = useCallback(async () => {
|
||||
const track = String(activeTrack || requestModal.trackNumber || "").trim().toUpperCase();
|
||||
const cursor = getOldestMessageCursor(requestModal.messages);
|
||||
if (!track || requestModal.messagesLoadingMore || !requestModal.messagesHasMore) return null;
|
||||
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: true }));
|
||||
try {
|
||||
const query = new URLSearchParams({ include_body: "false" });
|
||||
if (cursor?.beforeId && cursor?.beforeCreatedAt) {
|
||||
query.set("before_id", cursor.beforeId);
|
||||
query.set("before_created_at", cursor.beforeCreatedAt);
|
||||
} else {
|
||||
query.set("before_count", String(Number(requestModal.messagesLoadedCount || 0)));
|
||||
}
|
||||
const payload = await apiJson(
|
||||
"/api/public/chat/requests/" + encodeURIComponent(track) + "/messages-window?" + query.toString(),
|
||||
null,
|
||||
"\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0438\u0441\u0442\u043E\u0440\u0438\u044E \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0439"
|
||||
);
|
||||
const nextRows = Array.isArray(payload?.rows) ? payload.rows : [];
|
||||
setRequestModal((prev) => ({
|
||||
...prev,
|
||||
messagesLoadingMore: false,
|
||||
...(function() {
|
||||
const merged = mergeRowsById(nextRows, prev.messages);
|
||||
return {
|
||||
messages: merged,
|
||||
messagesHasMore: Boolean(payload?.has_more),
|
||||
messagesLoadedCount: merged.length,
|
||||
messagesTotal: Number(prev.messagesTotal || merged.length || 0)
|
||||
};
|
||||
})()
|
||||
}));
|
||||
void hydratePublicMessageBodies(track, nextRows);
|
||||
return payload || null;
|
||||
} catch (error) {
|
||||
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: false }));
|
||||
setPageStatus(error?.message || "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0438\u0441\u0442\u043E\u0440\u0438\u044E \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0439", "error");
|
||||
return null;
|
||||
}
|
||||
}, [
|
||||
activeTrack,
|
||||
apiJson,
|
||||
requestModal.messagesHasMore,
|
||||
requestModal.messagesLoadedCount,
|
||||
requestModal.messagesLoadingMore,
|
||||
requestModal.trackNumber,
|
||||
setPageStatus,
|
||||
hydratePublicMessageBodies
|
||||
]);
|
||||
const refreshRequestsList = useCallback(async () => {
|
||||
const data = await apiJson("/api/public/requests/my", null, "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0441\u043F\u0438\u0441\u043E\u043A \u0437\u0430\u044F\u0432\u043E\u043A");
|
||||
const rows = Array.isArray(data?.rows) ? data.rows : [];
|
||||
|
|
@ -2777,6 +2894,10 @@
|
|||
statusRouteNodes: [],
|
||||
statusHistory: [],
|
||||
messages: [],
|
||||
messagesHasMore: false,
|
||||
messagesLoadingMore: false,
|
||||
messagesLoadedCount: 0,
|
||||
messagesTotal: 0,
|
||||
attachments: [],
|
||||
fileUploading: false,
|
||||
selectedFiles: [],
|
||||
|
|
@ -2977,11 +3098,18 @@
|
|||
const nextMessages = Array.isArray(payload?.messages) ? payload.messages : [];
|
||||
const nextAttachments = Array.isArray(payload?.attachments) ? payload.attachments : [];
|
||||
if (nextMessages.length || nextAttachments.length) {
|
||||
setRequestModal((prev) => ({
|
||||
setRequestModal((prev) => {
|
||||
const mergedMessages = mergeRowsById(prev.messages, nextMessages);
|
||||
const previousCount = Array.isArray(prev.messages) ? prev.messages.length : 0;
|
||||
const addedCount = Math.max(0, mergedMessages.length - previousCount);
|
||||
return {
|
||||
...prev,
|
||||
messages: mergeRowsById(prev.messages, nextMessages),
|
||||
messages: mergedMessages,
|
||||
messagesLoadedCount: Number(prev.messagesLoadedCount || previousCount) + addedCount,
|
||||
messagesTotal: Number(prev.messagesTotal || previousCount) + addedCount,
|
||||
attachments: mergeRowsById(prev.attachments, nextAttachments)
|
||||
}));
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
return payload || { has_updates: false, typing: [], cursor: null };
|
||||
|
|
@ -3148,6 +3276,8 @@
|
|||
currentImportantDateAt: requestModal.currentImportantDateAt || "",
|
||||
pendingStatusChangePreset: null,
|
||||
messages: requestModal.messages || [],
|
||||
messagesHasMore: Boolean(requestModal.messagesHasMore),
|
||||
messagesLoadingMore: Boolean(requestModal.messagesLoadingMore),
|
||||
attachments: requestModal.attachments || [],
|
||||
messageDraft: requestModal.messageDraft || "",
|
||||
selectedFiles: requestModal.selectedFiles || [],
|
||||
|
|
@ -3155,6 +3285,7 @@
|
|||
status,
|
||||
onMessageChange: updateMessageDraft,
|
||||
onSendMessage: submitMessage,
|
||||
onLoadOlderMessages: loadOlderPublicMessages,
|
||||
onFilesSelect: appendFiles,
|
||||
onRemoveSelectedFile: removeFile,
|
||||
onClearSelectedFiles: clearFiles,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,22 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
|||
return sortRowsByCreatedAt(Array.from(merged.values()));
|
||||
}
|
||||
|
||||
function getOldestMessageCursor(rows) {
|
||||
const sorted = sortRowsByCreatedAt(Array.isArray(rows) ? rows : []);
|
||||
const first = sorted[0];
|
||||
if (!first) return null;
|
||||
const beforeId = String(first.id || "").trim();
|
||||
const beforeCreatedAt = String(first.created_at || first.updated_at || "").trim();
|
||||
if (!beforeId || !beforeCreatedAt) return null;
|
||||
return { beforeId, beforeCreatedAt };
|
||||
}
|
||||
|
||||
function collectDeferredMessageIds(rows) {
|
||||
return (Array.isArray(rows) ? rows : [])
|
||||
.filter((row) => row && typeof row === "object" && row.body_loaded === false && String(row.id || "").trim())
|
||||
.map((row) => String(row.id).trim());
|
||||
}
|
||||
|
||||
function StatusLine({ status }) {
|
||||
return <p className={"status" + (status?.kind ? " " + status.kind : "")}>{status?.message || ""}</p>;
|
||||
}
|
||||
|
|
@ -599,6 +615,38 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
|||
return completeData;
|
||||
}, [apiJson, buildStorageUploadError, requestModal.requestId, runUploadStepWithRetry]);
|
||||
|
||||
const hydratePublicMessageBodies = useCallback(
|
||||
async (trackNumber, rows) => {
|
||||
const track = String(trackNumber || "").trim().toUpperCase();
|
||||
const ids = collectDeferredMessageIds(rows);
|
||||
if (!track || !ids.length) return null;
|
||||
try {
|
||||
const payload = await apiJson(
|
||||
"/api/public/chat/requests/" + encodeURIComponent(track) + "/message-bodies",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
},
|
||||
"Не удалось загрузить тексты сообщений"
|
||||
);
|
||||
const nextRows = Array.isArray(payload?.rows) ? payload.rows : [];
|
||||
if (!nextRows.length) return payload || null;
|
||||
setRequestModal((prev) => {
|
||||
if (String(prev.trackNumber || "").trim().toUpperCase() !== track) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: mergeRowsById(prev.messages, nextRows),
|
||||
};
|
||||
});
|
||||
return payload || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[apiJson]
|
||||
);
|
||||
|
||||
const loadRequestWorkspace = useCallback(
|
||||
async (trackNumber, showLoading) => {
|
||||
const track = String(trackNumber || "").trim().toUpperCase();
|
||||
|
|
@ -608,7 +656,11 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
|||
}
|
||||
const [requestData, messagesData, attachmentsData, invoicesData, statusRouteData, serviceRequestsData] = await Promise.all([
|
||||
apiJson("/api/public/requests/" + encodeURIComponent(track), null, "Не удалось открыть заявку"),
|
||||
apiJson("/api/public/chat/requests/" + encodeURIComponent(track) + "/messages-window", null, "Не удалось загрузить сообщения"),
|
||||
apiJson(
|
||||
"/api/public/chat/requests/" + encodeURIComponent(track) + "/messages-window?include_body=false",
|
||||
null,
|
||||
"Не удалось загрузить сообщения"
|
||||
),
|
||||
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/attachments", null, "Не удалось загрузить файлы"),
|
||||
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/invoices", null, "Не удалось загрузить счета"),
|
||||
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/status-route", null, "Не удалось загрузить маршрут статусов"),
|
||||
|
|
@ -652,37 +704,49 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
|||
messages: Array.isArray(messagesData?.rows) ? messagesData.rows : [],
|
||||
messagesHasMore: Boolean(messagesData?.has_more),
|
||||
messagesLoadingMore: false,
|
||||
messagesLoadedCount: Number(messagesData?.loaded_count || 0),
|
||||
messagesTotal: Number(messagesData?.total || 0),
|
||||
messagesLoadedCount: Array.isArray(messagesData?.rows) ? messagesData.rows.length : 0,
|
||||
messagesTotal: Number(messagesData?.total || (Array.isArray(messagesData?.rows) ? messagesData.rows.length : 0)),
|
||||
attachments: Array.isArray(attachmentsData) ? attachmentsData : [],
|
||||
fileUploading: false,
|
||||
}));
|
||||
void hydratePublicMessageBodies(track, Array.isArray(messagesData?.rows) ? messagesData.rows : []);
|
||||
},
|
||||
[apiJson]
|
||||
[apiJson, hydratePublicMessageBodies]
|
||||
);
|
||||
|
||||
const loadOlderPublicMessages = useCallback(async () => {
|
||||
const track = String(activeTrack || requestModal.trackNumber || "").trim().toUpperCase();
|
||||
const loadedCount = Number(requestModal.messagesLoadedCount || 0);
|
||||
const cursor = getOldestMessageCursor(requestModal.messages);
|
||||
if (!track || requestModal.messagesLoadingMore || !requestModal.messagesHasMore) return null;
|
||||
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: true }));
|
||||
try {
|
||||
const query = new URLSearchParams({ include_body: "false" });
|
||||
if (cursor?.beforeId && cursor?.beforeCreatedAt) {
|
||||
query.set("before_id", cursor.beforeId);
|
||||
query.set("before_created_at", cursor.beforeCreatedAt);
|
||||
} else {
|
||||
query.set("before_count", String(Number(requestModal.messagesLoadedCount || 0)));
|
||||
}
|
||||
const payload = await apiJson(
|
||||
"/api/public/chat/requests/" +
|
||||
encodeURIComponent(track) +
|
||||
"/messages-window?before_count=" +
|
||||
encodeURIComponent(String(loadedCount)),
|
||||
"/api/public/chat/requests/" + encodeURIComponent(track) + "/messages-window?" + query.toString(),
|
||||
null,
|
||||
"Не удалось загрузить историю сообщений"
|
||||
);
|
||||
const nextRows = Array.isArray(payload?.rows) ? payload.rows : [];
|
||||
setRequestModal((prev) => ({
|
||||
...prev,
|
||||
messagesLoadingMore: false,
|
||||
messages: mergeRowsById(payload?.rows || [], prev.messages),
|
||||
...(function () {
|
||||
const merged = mergeRowsById(nextRows, prev.messages);
|
||||
return {
|
||||
messages: merged,
|
||||
messagesHasMore: Boolean(payload?.has_more),
|
||||
messagesLoadedCount: Number(payload?.loaded_count || prev.messagesLoadedCount || 0),
|
||||
messagesTotal: Number(payload?.total || prev.messagesTotal || 0),
|
||||
messagesLoadedCount: merged.length,
|
||||
messagesTotal: Number(prev.messagesTotal || merged.length || 0),
|
||||
};
|
||||
})(),
|
||||
}));
|
||||
void hydratePublicMessageBodies(track, nextRows);
|
||||
return payload || null;
|
||||
} catch (error) {
|
||||
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: false }));
|
||||
|
|
@ -697,6 +761,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
|||
requestModal.messagesLoadingMore,
|
||||
requestModal.trackNumber,
|
||||
setPageStatus,
|
||||
hydratePublicMessageBodies,
|
||||
]);
|
||||
|
||||
const refreshRequestsList = useCallback(async () => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
| PERF-04 | Собрать единый endpoint карточки заявки | in_progress | P0 | PERF-01 |
|
||||
| PERF-05 | Выделить узкие request-scoped endpoints для вложений и счетов | completed | P0 | PERF-04 |
|
||||
| PERF-06 | Переписать kanban на SQL-first фильтрацию/limit | in_progress | P0 | PERF-01, PERF-02 |
|
||||
| PERF-07 | Ограничить initial chat payload и добавить догрузку истории | in_progress | P1 | PERF-03, PERF-04 |
|
||||
| PERF-07 | Ограничить initial chat payload и добавить догрузку истории | completed | P1 | PERF-03, PERF-04 |
|
||||
| PERF-08 | Добавить нужные вспомогательные индексы и повторный profiling | planned | P1 | PERF-01 |
|
||||
|
||||
## PERF-01
|
||||
|
|
@ -80,6 +80,17 @@
|
|||
- 2026-03-17: admin avatar proxy умеет по `variant=thumb` отдавать сжатый вариант и, если его еще нет, достраивать его на лету из оригинала; public featured staff URLs тоже переключены на `?variant=thumb` и умеют так же достраивать thumb на лету.
|
||||
- 2026-03-17: `workspace` упрощен server-side: убрано дублирующее `get_request_service() + db.get(Request)` внутри одного запроса, read-mark side effects сведены в один проход, `mark_admin_notifications_read` переведен на bulk update, `status_route` повторно использует уже загруженный `Request`.
|
||||
- 2026-03-17: контейнерные регрессы после avatar/workspace правок пройдены: `tests.test_uploads_s3`, `tests.test_featured_staff_public`, `tests.admin.test_lawyer_chat`.
|
||||
- 2026-03-17: для admin UI первый `workspace` переведен в lean-режим: `/api/admin/requests/{id}/workspace?include_related=false` теперь отдает только заявку, чат-окно и базовый finance summary, а `attachments / invoices / status-route` догружаются фоном отдельными endpoint. Это режет количество SQL round-trip в критическом пути на проде с медленной БД.
|
||||
- 2026-03-17: добавлена внутренняя инструментализация `workspace/status-route/serialize_messages` через `uvicorn.error`, чтобы видеть step-by-step ms в контейнерных логах без отдельного profiler.
|
||||
- 2026-03-17: живой локальный профиль подтвердил bottleneck: почти весь `workspace` уходит в `messages_query_ms`, а не в `status-route` или дополнительных запросах. На чате в `2000` сообщений: при initial window `50` `messages_query_ms ~569 ms`, после уменьшения initial window до `20` `messages_query_ms ~239 ms`.
|
||||
- 2026-03-17: корневая причина находится в загрузке `Message` rows с `EncryptedChatText`: дешифровка `Message.body` выполняется на materialize каждого ORM row и дает почти линейную стоимость по числу сообщений в initial window.
|
||||
- 2026-03-17: chat crypto переработан без ослабления защиты: новые сообщения пишутся в `chatenc:v3` с `per-chat` data key, завернутым master chat key в `Request.extra_fields.chat_crypto`; чтение `v1/v2` сохранено для обратной совместимости.
|
||||
- 2026-03-17: `Message.body` больше не auto-decrypt в ORM. Шифрование тела выполняется на `before_flush`, а дешифровка вынесена в `chat_secure_service` и вызывается только там, где действительно нужен текст сообщения.
|
||||
- 2026-03-17: admin/public `messages-window` переведены на cursor-параметры `before_id + before_created_at` с сохранением fallback `before_count` для совместимости; UI admin/client переключен на cursor path.
|
||||
- 2026-03-17: initial chat payload для admin workspace и public cabinet стал metadata-first: `workspace/messages-window` могут отдавать `body_loaded=false`, а тексты догружаются отдельным `message-bodies` batch endpoint только для реально показанных сообщений.
|
||||
- 2026-03-17: добавлены новые endpoint `POST /api/admin/chat/requests/{id}/message-bodies` и `POST /api/public/chat/requests/{track}/message-bodies` с тем же RBAC/session-scope, что и чтение чата.
|
||||
- 2026-03-17: reencrypt path обновлен под новый `v3` формат - `app/scripts/reencrypt_with_active_kid.py` теперь мигрирует legacy chat rows в request-scoped AEAD и одновременно заполняет `Request.extra_fields.chat_crypto`.
|
||||
- 2026-03-17: контейнерный регресс нового chat stack пройден: `tests.test_reencrypt_with_active_kid`, `tests.test_public_cabinet`, `tests.admin.test_lawyer_chat`, `tests.test_invoices`, `tests.test_crypto_kid_rotation`, `tests.test_http_hardening` (`45 tests OK`).
|
||||
|
||||
## Дальше
|
||||
|
||||
|
|
|
|||
|
|
@ -16,3 +16,4 @@ python-multipart==0.0.22
|
|||
smsaero-api-async
|
||||
Pillow==11.2.1
|
||||
reportlab==4.2.2
|
||||
cryptography==45.0.7
|
||||
|
|
|
|||
|
|
@ -384,24 +384,50 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
|
|||
self.assertEqual(own_workspace.status_code, 200)
|
||||
payload = own_workspace.json()
|
||||
self.assertEqual(str((payload.get("request") or {}).get("id")), own_id)
|
||||
self.assertEqual(len(payload.get("messages") or []), 50)
|
||||
self.assertEqual(len(payload.get("messages") or []), 20)
|
||||
self.assertTrue(bool(payload.get("messages_has_more")))
|
||||
self.assertEqual(int(payload.get("messages_total") or 0), 55)
|
||||
self.assertEqual(int(payload.get("messages_loaded_count") or 0), 50)
|
||||
self.assertEqual(int(payload.get("messages_loaded_count") or 0), 20)
|
||||
self.assertTrue(all(item.get("body_loaded") is False for item in (payload.get("messages") or [])))
|
||||
self.assertEqual(len(payload.get("attachments") or []), 1)
|
||||
self.assertIn("status_route", payload)
|
||||
self.assertIn("finance_summary", payload)
|
||||
|
||||
lean_workspace = self.client.get(f"/api/admin/requests/{own_id}/workspace?include_related=false", headers=headers)
|
||||
self.assertEqual(lean_workspace.status_code, 200)
|
||||
lean_payload = lean_workspace.json()
|
||||
self.assertEqual(str((lean_payload.get("request") or {}).get("id")), own_id)
|
||||
self.assertEqual(len(lean_payload.get("messages") or []), 20)
|
||||
self.assertEqual(len(lean_payload.get("attachments") or []), 0)
|
||||
self.assertEqual(len(lean_payload.get("invoices") or []), 0)
|
||||
self.assertEqual((lean_payload.get("status_route") or {}).get("nodes") or [], [])
|
||||
|
||||
body_batch = self.chat_client.post(
|
||||
f"/api/admin/chat/requests/{own_id}/message-bodies",
|
||||
headers=headers,
|
||||
json={"ids": [item["id"] for item in (payload.get("messages") or [])[:3]]},
|
||||
)
|
||||
self.assertEqual(body_batch.status_code, 200)
|
||||
self.assertEqual(len(body_batch.json().get("rows") or []), 3)
|
||||
self.assertTrue(all(item.get("body_loaded") for item in (body_batch.json().get("rows") or [])))
|
||||
|
||||
oldest_loaded = (payload.get("messages") or [])[0]
|
||||
|
||||
older_messages = self.chat_client.get(
|
||||
f"/api/admin/chat/requests/{own_id}/messages-window",
|
||||
headers=headers,
|
||||
params={"before_count": 50, "limit": 10},
|
||||
params={
|
||||
"before_id": str(oldest_loaded.get("id") or ""),
|
||||
"before_created_at": str(oldest_loaded.get("created_at") or ""),
|
||||
"limit": 10,
|
||||
"include_body": "false",
|
||||
},
|
||||
)
|
||||
self.assertEqual(older_messages.status_code, 200)
|
||||
older_payload = older_messages.json()
|
||||
self.assertEqual(len(older_payload.get("rows") or []), 5)
|
||||
self.assertFalse(bool(older_payload.get("has_more")))
|
||||
self.assertEqual(int(older_payload.get("loaded_count") or 0), 55)
|
||||
self.assertEqual(len(older_payload.get("rows") or []), 10)
|
||||
self.assertTrue(bool(older_payload.get("has_more")))
|
||||
self.assertTrue(all(item.get("body_loaded") is False for item in (older_payload.get("rows") or [])))
|
||||
|
||||
foreign_workspace = self.client.get(f"/api/admin/requests/{foreign_id}/workspace", headers=headers)
|
||||
self.assertEqual(foreign_workspace.status_code, 403)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,13 @@ os.environ.setdefault("S3_SECRET_KEY", "test")
|
|||
os.environ.setdefault("S3_BUCKET", "test")
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.chat_crypto import decrypt_message_body, encrypt_message_body, extract_message_kid
|
||||
from app.services.chat_crypto import (
|
||||
decrypt_message_body,
|
||||
decrypt_message_body_for_request,
|
||||
encrypt_message_body,
|
||||
encrypt_message_body_for_request,
|
||||
extract_message_kid,
|
||||
)
|
||||
from app.services.invoice_crypto import (
|
||||
active_requisites_kid,
|
||||
decrypt_requisites,
|
||||
|
|
@ -110,6 +116,24 @@ class CryptoKidRotationTests(unittest.TestCase):
|
|||
self.assertEqual(extract_message_kid(token), "k2")
|
||||
self.assertEqual(decrypt_message_body(token), "legacy message")
|
||||
|
||||
def test_chat_request_crypto_uses_per_chat_v3_format(self):
|
||||
settings.DATA_ENCRYPTION_SECRET = ""
|
||||
settings.DATA_ENCRYPTION_ACTIVE_KID = "k2"
|
||||
settings.DATA_ENCRYPTION_KEYS = "k2=new-data-secret-bbbbbbbbbbbbbbbb"
|
||||
settings.CHAT_ENCRYPTION_SECRET = ""
|
||||
settings.CHAT_ENCRYPTION_ACTIVE_KID = "k2"
|
||||
settings.CHAT_ENCRYPTION_KEYS = "k2=new-chat-secret-cccccccccccccccc"
|
||||
|
||||
token, extra_fields, changed = encrypt_message_body_for_request("request scoped", request_extra_fields={})
|
||||
self.assertTrue(changed)
|
||||
self.assertTrue(str(token).startswith("chatenc:v3:"))
|
||||
self.assertEqual(extract_message_kid(token), "k2")
|
||||
self.assertTrue(bool((extra_fields or {}).get("chat_crypto")))
|
||||
self.assertEqual(
|
||||
decrypt_message_body_for_request(token, request_extra_fields=extra_fields),
|
||||
"request scoped",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from app.models.invoice import Invoice
|
|||
from app.models.message import Message
|
||||
from app.models.notification import Notification
|
||||
from app.models.request import Request
|
||||
from app.services.chat_crypto import decrypt_message_body_for_request
|
||||
from app.services.invoice_crypto import decrypt_requisites
|
||||
|
||||
|
||||
|
|
@ -190,7 +191,10 @@ class InvoiceApiTests(unittest.TestCase):
|
|||
self.assertEqual(decrypted["kpp"], "770001001")
|
||||
message = db.query(Message).filter(Message.request_id == UUID(self.request_a_id)).order_by(Message.created_at.desc()).first()
|
||||
self.assertIsNotNone(message)
|
||||
self.assertEqual(message.body, "Счет на оплату")
|
||||
self.assertEqual(
|
||||
decrypt_message_body_for_request(message.body, request_extra_fields=row_request.extra_fields if (row_request := db.get(Request, UUID(self.request_a_id))) else {}),
|
||||
"Счет на оплату",
|
||||
)
|
||||
attachment = (
|
||||
db.query(Attachment)
|
||||
.filter(Attachment.request_id == UUID(self.request_a_id), Attachment.message_id == message.id)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from app.models.attachment import Attachment
|
|||
from app.models.message import Message
|
||||
from app.models.notification import Notification
|
||||
from app.models.request import Request
|
||||
from app.services.chat_crypto import decrypt_message_body_for_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
|
||||
|
|
@ -217,9 +218,12 @@ class PublicCabinetTests(unittest.TestCase):
|
|||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row.request_id, request_id)
|
||||
self.assertEqual(row.author_type, "CLIENT")
|
||||
self.assertEqual(row.body, "Добрый день, есть вопрос по документам.")
|
||||
req = db.get(Request, request_id)
|
||||
self.assertIsNotNone(req)
|
||||
self.assertEqual(
|
||||
decrypt_message_body_for_request(row.body, request_extra_fields=req.extra_fields),
|
||||
"Добрый день, есть вопрос по документам.",
|
||||
)
|
||||
self.assertEqual(req.responsible, "Клиент")
|
||||
self.assertTrue(req.lawyer_has_unread_updates)
|
||||
self.assertEqual(req.lawyer_unread_event_type, "MESSAGE")
|
||||
|
|
@ -288,18 +292,33 @@ class PublicCabinetTests(unittest.TestCase):
|
|||
listed_window = self.chat_client.get(
|
||||
"/api/public/chat/requests/TRK-CHAT-001/messages-window",
|
||||
cookies=cookies,
|
||||
params={"limit": 2},
|
||||
params={"limit": 2, "include_body": "false"},
|
||||
)
|
||||
self.assertEqual(listed_window.status_code, 200)
|
||||
window_payload = listed_window.json()
|
||||
self.assertEqual(len(window_payload.get("rows") or []), 2)
|
||||
self.assertTrue(bool(window_payload.get("has_more")))
|
||||
self.assertEqual(int(window_payload.get("loaded_count") or 0), 2)
|
||||
self.assertEqual(int(window_payload.get("total") or 0), 5)
|
||||
self.assertTrue(all(item.get("body_loaded") is False for item in (window_payload.get("rows") or [])))
|
||||
|
||||
body_batch = self.chat_client.post(
|
||||
"/api/public/chat/requests/TRK-CHAT-001/message-bodies",
|
||||
cookies=cookies,
|
||||
json={"ids": [item["id"] for item in (window_payload.get("rows") or [])]},
|
||||
)
|
||||
self.assertEqual(body_batch.status_code, 200)
|
||||
self.assertEqual(len(body_batch.json().get("rows") or []), 2)
|
||||
self.assertTrue(all(item.get("body_loaded") for item in (body_batch.json().get("rows") or [])))
|
||||
|
||||
denied = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER"))
|
||||
self.assertEqual(denied.status_code, 404)
|
||||
|
||||
denied_batch = self.chat_client.post(
|
||||
"/api/public/chat/requests/TRK-CHAT-001/message-bodies",
|
||||
cookies=self._public_cookies("TRK-OTHER"),
|
||||
json={"ids": [item["id"] for item in (window_payload.get("rows") or [])]},
|
||||
)
|
||||
self.assertEqual(denied_batch.status_code, 404)
|
||||
|
||||
def test_public_chat_marks_delivery_and_read_receipts_for_staff_messages(self):
|
||||
with self.SessionLocal() as db:
|
||||
req = Request(
|
||||
|
|
@ -369,8 +388,11 @@ class PublicCabinetTests(unittest.TestCase):
|
|||
|
||||
with self.SessionLocal() as db:
|
||||
raw_encrypted = db.execute(text("SELECT body FROM messages ORDER BY created_at DESC LIMIT 1")).scalar_one()
|
||||
self.assertTrue(str(raw_encrypted).startswith("chatenc:"))
|
||||
self.assertTrue(str(raw_encrypted).startswith("chatenc:v3:"))
|
||||
self.assertNotEqual(str(raw_encrypted), payload_body)
|
||||
request_row = db.query(Request).filter(Request.track_number == "TRK-CHAT-ENC").first()
|
||||
self.assertIsNotNone(request_row)
|
||||
self.assertTrue(bool((request_row.extra_fields or {}).get("chat_crypto")))
|
||||
|
||||
listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-ENC/messages", cookies=cookies)
|
||||
self.assertEqual(listed.status_code, 200)
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ class ReencryptWithKidTests(unittest.TestCase):
|
|||
)
|
||||
db.flush()
|
||||
db.execute(
|
||||
text("UPDATE messages SET body = :body WHERE id = (SELECT id FROM messages ORDER BY created_at DESC LIMIT 1)"),
|
||||
text("UPDATE messages SET body = :body WHERE rowid = (SELECT rowid FROM messages ORDER BY created_at DESC LIMIT 1)"),
|
||||
{"body": _legacy_chat_token("legacy body", old_secret)},
|
||||
)
|
||||
|
||||
|
|
@ -177,10 +177,13 @@ class ReencryptWithKidTests(unittest.TestCase):
|
|||
invoice_token = db.execute(text("SELECT payer_details_encrypted FROM invoices LIMIT 1")).scalar_one()
|
||||
admin_token = db.execute(text("SELECT totp_secret_encrypted FROM admin_users LIMIT 1")).scalar_one()
|
||||
message_token = db.execute(text("SELECT body FROM messages LIMIT 1")).scalar_one()
|
||||
request_row = db.execute(text("SELECT extra_fields FROM requests LIMIT 1")).scalar_one()
|
||||
|
||||
self.assertEqual(extract_requisites_kid(str(invoice_token)), "k2")
|
||||
self.assertEqual(extract_requisites_kid(str(admin_token)), "k2")
|
||||
self.assertTrue(str(message_token).startswith("chatenc:v3:"))
|
||||
self.assertEqual(extract_message_kid(str(message_token)), "k2")
|
||||
self.assertIn("chat_crypto", str(request_row))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
Loading…
Reference in a new issue