add deploy

This commit is contained in:
TronoSfera 2026-02-28 11:45:08 +03:00
parent df80b5cb5f
commit 9c0457f07f
37 changed files with 1450 additions and 299 deletions

View file

@ -1,5 +1,5 @@
# Legal Case Tracker (FastAPI) # Legal Case Tracker (FastAPI)
Backend skeleton: public requests + OTP + public JWT cookie + admin (admin/lawyer) + files (self-hosted S3) + SLA/auto-assign (Celery) + quotes. Backend skeleton: public requests + OTP + public JWT cookie + admin (admin/lawyer) + files (self-hosted S3) + SLA/auto-assign (Celery) + quotes + dedicated chat microservice.
## Run (Docker) ## Run (Docker)
```bash ```bash
@ -10,6 +10,33 @@ Landing (frontend): http://localhost:8081
Admin UI: http://localhost:8081/admin Admin UI: http://localhost:8081/admin
API (backend): http://localhost:8002 API (backend): http://localhost:8002
Swagger: http://localhost:8002/docs Swagger: http://localhost:8002/docs
Chat service health (via nginx): http://localhost:8081/chat-health
## Production (ruakb.ru, 80/443, TLS)
Production is configured with a dedicated edge proxy (Caddy) in `docker-compose.prod.yml`.
Prerequisites:
- DNS `A` record: `ruakb.ru -> 45.150.36.116`
- Optional DNS `A` record: `www.ruakb.ru -> 45.150.36.116`
- Open server ports: `80/tcp`, `443/tcp`
Start/update production:
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec -T backend alembic upgrade head
```
Or use helper script:
```bash
./scripts/ops/deploy_prod.sh
```
Checks:
```bash
curl -I https://ruakb.ru
curl -fsS https://ruakb.ru/health
curl -fsS https://ruakb.ru/chat-health
```
## Migrations ## Migrations
```bash ```bash
@ -48,3 +75,45 @@ When enabled, real SMS sending is disabled and OTP code is printed to backend lo
Admin health-check endpoint (no SMS send): Admin health-check endpoint (no SMS send):
`GET /api/admin/system/sms-provider-health` `GET /api/admin/system/sms-provider-health`
## Secure Chat (encrypted at rest)
Chat logic is isolated in `app/services/chat_secure_service.py`.
- Message bodies are encrypted before storing in DB (`messages.body`) and transparently decrypted on read.
- Encryption key priority:
1. `CHAT_ENCRYPTION_SECRET`
2. `DATA_ENCRYPTION_SECRET`
3. JWT secrets fallback (not recommended for production)
Recommended production config:
```bash
CHAT_ENCRYPTION_SECRET=<long-random-secret>
DATA_ENCRYPTION_SECRET=<long-random-secret>
```
Chat API runs in a dedicated container (`chat-service`) with separate FastAPI entrypoint:
`app/chat_main.py`
Nginx routes only chat API prefixes to the chat container:
- `/api/public/chat/*`
- `/api/admin/chat/*`
## Container health and alerting
Docker Compose is configured with:
- `restart: unless-stopped` for core services
- `healthcheck` for `db`, `redis`, `backend`, `chat-service`, `frontend`
- startup ordering via `depends_on: condition: service_healthy`
Quick checks:
```bash
docker compose up -d
docker compose ps
curl -fsS http://localhost:8081/health
curl -fsS http://localhost:8081/chat-health
```
Alert-ready smoke script (for cron/CI):
```bash
./scripts/ops/check_chat_health.sh
```
Exit code `0` means healthy, non-zero means alert condition.

View file

@ -0,0 +1,52 @@
"""encrypt historical chat messages at rest
Revision ID: 0027_encrypt_chat_messages
Revises: 0026_srv_req_str_ids
Create Date: 2026-02-27 21:30:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from app.services.chat_crypto import decrypt_message_body, encrypt_message_body, is_encrypted_message
# revision identifiers, used by Alembic.
revision = "0027_encrypt_chat_messages"
down_revision = "0026_srv_req_str_ids"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
rows = bind.execute(sa.text("SELECT id, body FROM messages WHERE body IS NOT NULL")).mappings().all()
for row in rows:
message_id = row.get("id")
body = row.get("body")
body_text = str(body or "")
if not body_text or is_encrypted_message(body_text):
continue
encrypted = encrypt_message_body(body_text)
bind.execute(
sa.text("UPDATE messages SET body = :body WHERE id = :id"),
{"body": encrypted, "id": str(message_id)},
)
def downgrade() -> None:
bind = op.get_bind()
rows = bind.execute(sa.text("SELECT id, body FROM messages WHERE body IS NOT NULL")).mappings().all()
for row in rows:
message_id = row.get("id")
body = row.get("body")
body_text = str(body or "")
if not body_text or not is_encrypted_message(body_text):
continue
decrypted = decrypt_message_body(body_text)
bind.execute(
sa.text("UPDATE messages SET body = :body WHERE id = :id"),
{"body": decrypted, "id": str(message_id)},
)

View file

@ -16,7 +16,9 @@ from app.models.request_data_requirement import RequestDataRequirement
from app.models.request_data_template import RequestDataTemplate from app.models.request_data_template import RequestDataTemplate
from app.models.request_data_template_item import RequestDataTemplateItem from app.models.request_data_template_item import RequestDataTemplateItem
from app.models.topic_data_template import TopicDataTemplate from app.models.topic_data_template import TopicDataTemplate
from app.services.chat_service import ( from app.services.notifications import EVENT_REQUEST_DATA as NOTIFICATION_EVENT_REQUEST_DATA, notify_request_event, unread_admin_summary
from app.services.request_read_markers import EVENT_REQUEST_DATA, mark_unread_for_client
from app.services.chat_secure_service import (
create_admin_or_lawyer_message, create_admin_or_lawyer_message,
get_chat_activity_summary, get_chat_activity_summary,
list_messages_for_request, list_messages_for_request,
@ -291,6 +293,11 @@ def get_request_live_state(
"latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))), "latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))),
"latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))), "latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))),
"typing": typing_rows, "typing": typing_rows,
"unread": unread_admin_summary(
db,
admin_user_id=str(admin.get("sub") or ""),
request_id=req.id,
),
} }
@ -597,6 +604,7 @@ def upsert_data_request_batch(
req = _request_for_id_or_404(db, request_id) req = _request_for_id_or_404(db, request_id)
_ensure_lawyer_can_manage_request_or_403(admin, req) _ensure_lawyer_can_manage_request_or_403(admin, req)
actor_role = str(admin.get("role") or "").strip().upper() actor_role = str(admin.get("role") or "").strip().upper()
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
body = payload or {} body = payload or {}
raw_items = body.get("items") raw_items = body.get("items")
@ -639,6 +647,7 @@ def upsert_data_request_batch(
actor_role=role, actor_role=role,
actor_name=actor_name, actor_name=actor_name,
actor_admin_user_id=actor_admin_user_id, actor_admin_user_id=actor_admin_user_id,
event_type=NOTIFICATION_EVENT_REQUEST_DATA,
) )
message_uuid = existing_message.id message_uuid = existing_message.id
@ -770,6 +779,18 @@ def upsert_data_request_batch(
for row in existing_message_rows: for row in existing_message_rows:
if row.key not in touched_keys: if row.key not in touched_keys:
db.delete(row) db.delete(row)
mark_unread_for_client(req, EVENT_REQUEST_DATA)
req.responsible = responsible
db.add(req)
notify_request_event(
db,
request=req,
event_type=NOTIFICATION_EVENT_REQUEST_DATA,
actor_role=actor_role,
actor_admin_user_id=admin.get("sub"),
body=f"Обновлен запрос дополнительных данных ({len(normalized_rows)})",
responsible=responsible,
)
db.commit() db.commit()
fresh_messages = list_messages_for_request(db, req.id) fresh_messages = list_messages_for_request(db, req.id)

View file

@ -18,15 +18,19 @@ from app.models.table_availability import TableAvailability
from app.schemas.universal import UniversalQuery from app.schemas.universal import UniversalQuery
from app.services.billing_flow import apply_billing_transition_effects from app.services.billing_flow import apply_billing_transition_effects
from app.services.notifications import ( from app.services.notifications import (
EVENT_ASSIGNMENT as NOTIFICATION_EVENT_ASSIGNMENT,
EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT,
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
EVENT_REASSIGNMENT as NOTIFICATION_EVENT_REASSIGNMENT,
EVENT_STATUS as NOTIFICATION_EVENT_STATUS, EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
mark_admin_notifications_read, mark_admin_notifications_read,
notify_request_event, notify_request_event,
) )
from app.services.request_read_markers import ( from app.services.request_read_markers import (
EVENT_ASSIGNMENT,
EVENT_ATTACHMENT, EVENT_ATTACHMENT,
EVENT_MESSAGE, EVENT_MESSAGE,
EVENT_REASSIGNMENT,
EVENT_STATUS, EVENT_STATUS,
clear_unread_for_lawyer, clear_unread_for_lawyer,
mark_unread_for_client, mark_unread_for_client,
@ -123,6 +127,26 @@ def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin:
body=f"Файл: {row.file_name}", body=f"Файл: {row.file_name}",
responsible=responsible, responsible=responsible,
) )
return
if table_name == "requests" and isinstance(row, Request):
assigned = str(row.assigned_lawyer_id or "").strip()
if not assigned:
return
mark_unread_for_client(row, EVENT_ASSIGNMENT)
mark_unread_for_lawyer(row, EVENT_ASSIGNMENT)
responsible = _resolve_responsible(admin)
row.responsible = responsible
db.add(row)
notify_request_event(
db,
request=row,
event_type=NOTIFICATION_EVENT_ASSIGNMENT,
actor_role=_actor_role(admin),
actor_admin_user_id=admin.get("sub"),
body=f"Назначен юрист: {assigned}",
responsible=responsible,
)
def list_tables_meta_service(db: Session, admin: dict) -> dict[str, Any]: def list_tables_meta_service(db: Session, admin: dict) -> dict[str, Any]:
@ -449,6 +473,7 @@ def update_row_service(table_name: str, row_id: str, payload: dict[str, Any], db
if "responsible" in _columns_map(model): if "responsible" in _columns_map(model):
clean_payload["responsible"] = responsible clean_payload["responsible"] = responsible
before = _row_to_dict(row) before = _row_to_dict(row)
before_assigned_lawyer_id = str(before.get("assigned_lawyer_id") or "").strip() if normalized == "requests" else ""
if normalized == "topic_status_transitions": if normalized == "topic_status_transitions":
next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip() next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip()
next_to = str(clean_payload.get("to_status", before.get("to_status") or "")).strip() next_to = str(clean_payload.get("to_status", before.get("to_status") or "")).strip()
@ -510,8 +535,35 @@ def update_row_service(table_name: str, row_id: str, payload: dict[str, Any], db
), ),
responsible=responsible, responsible=responsible,
) )
assignment_event_type = None
assignment_marker_type = None
assignment_event_body = None
if normalized == "requests" and not _is_lawyer(admin):
after_assigned_candidate = clean_payload.get("assigned_lawyer_id", before_assigned_lawyer_id or None)
after_assigned_lawyer_id = str(after_assigned_candidate or "").strip()
if after_assigned_lawyer_id and after_assigned_lawyer_id != before_assigned_lawyer_id:
if before_assigned_lawyer_id:
assignment_event_type = NOTIFICATION_EVENT_REASSIGNMENT
assignment_marker_type = EVENT_REASSIGNMENT
assignment_event_body = f"Переназначено: {before_assigned_lawyer_id} -> {after_assigned_lawyer_id}"
else:
assignment_event_type = NOTIFICATION_EVENT_ASSIGNMENT
assignment_marker_type = EVENT_ASSIGNMENT
assignment_event_body = f"Назначен юрист: {after_assigned_lawyer_id}"
for key, value in clean_payload.items(): for key, value in clean_payload.items():
setattr(row, key, value) setattr(row, key, value)
if assignment_event_type and assignment_marker_type and isinstance(row, Request):
mark_unread_for_client(row, assignment_marker_type)
mark_unread_for_lawyer(row, assignment_marker_type)
notify_request_event(
db,
request=row,
event_type=assignment_event_type,
actor_role=_actor_role(admin),
actor_admin_user_id=admin.get("sub"),
body=assignment_event_body,
responsible=responsible,
)
try: try:
db.add(row) db.add(row)

View file

@ -16,6 +16,11 @@ from app.models.request import Request
from app.models.request_service_request import RequestServiceRequest from app.models.request_service_request import RequestServiceRequest
from app.models.status import Status from app.models.status import Status
from app.models.status_history import StatusHistory from app.models.status_history import StatusHistory
from app.services.notifications import (
unread_admin_summary,
unread_global_summary_for_clients,
unread_global_summary_for_lawyers,
)
from app.services.sla_metrics import compute_sla_snapshot from app.services.sla_metrics import compute_sla_snapshot
router = APIRouter() router = APIRouter()
@ -99,18 +104,25 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
now_utc = datetime.now(timezone.utc) now_utc = datetime.now(timezone.utc)
month_start, next_month_start = _month_bounds(now_utc) month_start, next_month_start = _month_bounds(now_utc)
unread_for_clients = ( unread_for_clients_flags = (
db.query(func.count(Request.id)) db.query(func.count(Request.id))
.filter(Request.client_has_unread_updates.is_(True)) .filter(Request.client_has_unread_updates.is_(True))
.scalar() .scalar()
or 0 or 0
) )
unread_for_lawyers = ( unread_for_lawyers_flags = (
db.query(func.count(Request.id)) db.query(func.count(Request.id))
.filter(Request.lawyer_has_unread_updates.is_(True)) .filter(Request.lawyer_has_unread_updates.is_(True))
.scalar() .scalar()
or 0 or 0
) )
unread_for_clients_notifications = unread_global_summary_for_clients(db)
unread_for_lawyers_notifications = unread_global_summary_for_lawyers(db)
unread_for_clients = max(int(unread_for_clients_flags), int(unread_for_clients_notifications.get("total") or 0))
unread_for_lawyers = max(int(unread_for_lawyers_flags), int(unread_for_lawyers_notifications.get("total") or 0))
my_unread_notifications = (
unread_admin_summary(db, admin_user_id=str(actor_uuid), request_id=None) if actor_uuid is not None else {"total": 0, "by_event": {}}
)
if role == "LAWYER" and actor_uuid is not None: if role == "LAWYER" and actor_uuid is not None:
service_request_unread_total = int( service_request_unread_total = int(
db.query(func.count(RequestServiceRequest.id)) db.query(func.count(RequestServiceRequest.id))
@ -267,6 +279,11 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
.all() .all()
) )
my_unread_by_event = {str(event_type): int(count) for event_type, count in my_unread_by_event_rows if event_type} my_unread_by_event = {str(event_type): int(count) for event_type, count in my_unread_by_event_rows if event_type}
notif_total = int(my_unread_notifications.get("total") or 0)
notif_by_event = dict(my_unread_notifications.get("by_event") or {})
if notif_total > my_unread_updates:
my_unread_updates = notif_total
my_unread_by_event = notif_by_event
scoped_lawyer_loads = [row for row in lawyer_loads if str(row["lawyer_id"]) == str(actor_uuid)] scoped_lawyer_loads = [row for row in lawyer_loads if str(row["lawyer_id"]) == str(actor_uuid)]
elif role == "LAWYER": elif role == "LAWYER":
by_status = {} by_status = {}
@ -293,8 +310,8 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
or 0 or 0
) )
unassigned_total = int(db.query(func.count(Request.id)).filter(Request.assigned_lawyer_id.is_(None)).scalar() or 0) unassigned_total = int(db.query(func.count(Request.id)).filter(Request.assigned_lawyer_id.is_(None)).scalar() or 0)
my_unread_updates = 0 my_unread_updates = int(my_unread_notifications.get("total") or 0)
my_unread_by_event = {} my_unread_by_event = dict(my_unread_notifications.get("by_event") or {})
scoped_lawyer_loads = lawyer_loads scoped_lawyer_loads = lawyer_loads
sla_snapshot = compute_sla_snapshot(db) sla_snapshot = compute_sla_snapshot(db)
@ -319,6 +336,8 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
"unassigned_total": unassigned_total, "unassigned_total": unassigned_total,
"my_unread_updates": my_unread_updates, "my_unread_updates": my_unread_updates,
"my_unread_by_event": my_unread_by_event, "my_unread_by_event": my_unread_by_event,
"my_unread_notifications_total": int(my_unread_notifications.get("total") or 0),
"my_unread_notifications_by_event": dict(my_unread_notifications.get("by_event") or {}),
"deadline_alert_total": deadline_alert_total, "deadline_alert_total": deadline_alert_total,
"month_revenue": monthly_revenue, "month_revenue": monthly_revenue,
"month_expenses": round(sum(_to_float(row.get("monthly_salary")) for row in scoped_lawyer_loads), 2) "month_expenses": round(sum(_to_float(row.get("monthly_salary")) for row in scoped_lawyer_loads), 2)
@ -331,6 +350,10 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
"avg_time_in_status_hours": sla_snapshot.get("avg_time_in_status_hours", {}), "avg_time_in_status_hours": sla_snapshot.get("avg_time_in_status_hours", {}),
"unread_for_clients": int(unread_for_clients), "unread_for_clients": int(unread_for_clients),
"unread_for_lawyers": int(unread_for_lawyers), "unread_for_lawyers": int(unread_for_lawyers),
"unread_for_clients_by_event": dict(unread_for_clients_notifications.get("by_event") or {}),
"unread_for_lawyers_by_event": dict(unread_for_lawyers_notifications.get("by_event") or {}),
"unread_for_clients_notifications_total": int(unread_for_clients_notifications.get("total") or 0),
"unread_for_lawyers_notifications_total": int(unread_for_lawyers_notifications.get("total") or 0),
"service_request_unread_total": int(service_request_unread_total), "service_request_unread_total": int(service_request_unread_total),
"lawyer_loads": scoped_lawyer_loads, "lawyer_loads": scoped_lawyer_loads,
} }

View file

@ -6,22 +6,32 @@ from uuid import UUID, uuid4
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import case, func, or_, update from sqlalchemy import case, func, or_, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.notification import Notification
from app.models.request import Request from app.models.request import Request
from app.models.request_service_request import RequestServiceRequest from app.models.request_service_request import RequestServiceRequest
from app.schemas.admin import RequestAdminCreate, RequestAdminPatch from app.schemas.admin import RequestAdminCreate, RequestAdminPatch
from app.schemas.universal import UniversalQuery from app.schemas.universal import UniversalQuery
from app.services.billing_flow import apply_billing_transition_effects from app.services.billing_flow import apply_billing_transition_effects
from app.services.notifications import ( from app.services.notifications import (
EVENT_ASSIGNMENT as NOTIFICATION_EVENT_ASSIGNMENT,
EVENT_REASSIGNMENT as NOTIFICATION_EVENT_REASSIGNMENT,
EVENT_STATUS as NOTIFICATION_EVENT_STATUS, EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
mark_admin_notifications_read, mark_admin_notifications_read,
notify_request_event, notify_request_event,
) )
from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client from app.services.request_read_markers import (
EVENT_ASSIGNMENT,
EVENT_REASSIGNMENT,
EVENT_STATUS,
clear_unread_for_lawyer,
mark_unread_for_client,
mark_unread_for_lawyer,
)
from app.services.request_status import apply_status_change_effects from app.services.request_status import apply_status_change_effects
from app.services.request_templates import validate_required_topic_fields_or_400 from app.services.request_templates import validate_required_topic_fields_or_400
from app.services.status_flow import transition_allowed_for_topic from app.services.status_flow import transition_allowed_for_topic
@ -68,6 +78,7 @@ def query_requests_service(uq: UniversalQuery, db: Session, admin: dict) -> dict
row_ids = [str(row.id) for row in rows if row and row.id] row_ids = [str(row.id) for row in rows if row and row.id]
unread_service_requests_by_request: dict[str, int] = {} unread_service_requests_by_request: dict[str, int] = {}
viewer_unread_by_request: dict[str, dict[str, Any]] = {}
if row_ids: if row_ids:
unread_query = ( unread_query = (
db.query(RequestServiceRequest.request_id, func.count(RequestServiceRequest.id)) db.query(RequestServiceRequest.request_id, func.count(RequestServiceRequest.id))
@ -84,6 +95,37 @@ def query_requests_service(uq: UniversalQuery, db: Session, admin: dict) -> dict
unread_rows = unread_query.group_by(RequestServiceRequest.request_id).all() unread_rows = unread_query.group_by(RequestServiceRequest.request_id).all()
unread_service_requests_by_request = {str(request_id): int(count or 0) for request_id, count in unread_rows if request_id} unread_service_requests_by_request = {str(request_id): int(count or 0) for request_id, count in unread_rows if request_id}
if actor:
try:
actor_uuid = UUID(str(actor))
except ValueError:
actor_uuid = None
if actor_uuid is not None:
try:
notif_rows = (
db.query(Notification.request_id, Notification.event_type, func.count(Notification.id))
.filter(
Notification.recipient_type == "ADMIN_USER",
Notification.recipient_admin_user_id == actor_uuid,
Notification.is_read.is_(False),
Notification.request_id.in_(row_ids),
)
.group_by(Notification.request_id, Notification.event_type)
.all()
)
except SQLAlchemyError:
notif_rows = []
for request_id, event_type, count in notif_rows:
request_key = str(request_id or "")
if not request_key:
continue
bucket = viewer_unread_by_request.setdefault(request_key, {"total": 0, "by_event": {}})
event_key = str(event_type or "").strip().upper()
event_count = int(count or 0)
if event_key:
bucket["by_event"][event_key] = int(bucket["by_event"].get(event_key, 0)) + event_count
bucket["total"] = int(bucket["total"]) + event_count
return { return {
"rows": [ "rows": [
{ {
@ -106,6 +148,8 @@ def query_requests_service(uq: UniversalQuery, db: Session, admin: dict) -> dict
"lawyer_unread_event_type": r.lawyer_unread_event_type, "lawyer_unread_event_type": r.lawyer_unread_event_type,
"service_requests_unread_count": int(unread_service_requests_by_request.get(str(r.id), 0)), "service_requests_unread_count": int(unread_service_requests_by_request.get(str(r.id), 0)),
"has_service_requests_unread": bool(unread_service_requests_by_request.get(str(r.id), 0)), "has_service_requests_unread": bool(unread_service_requests_by_request.get(str(r.id), 0)),
"viewer_unread_total": int((viewer_unread_by_request.get(str(r.id)) or {}).get("total", 0)),
"viewer_unread_by_event": dict((viewer_unread_by_request.get(str(r.id)) or {}).get("by_event", {})),
"created_at": r.created_at.isoformat() if r.created_at else None, "created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None, "updated_at": r.updated_at.isoformat() if r.updated_at else None,
} }
@ -193,6 +237,7 @@ def update_request_service(request_id: str, payload: RequestAdminPatch, db: Sess
if row.effective_rate is None and "effective_rate" not in changes: if row.effective_rate is None and "effective_rate" not in changes:
changes["effective_rate"] = assigned_lawyer.default_rate changes["effective_rate"] = assigned_lawyer.default_rate
old_status = str(row.status_code or "") old_status = str(row.status_code or "")
old_assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip()
responsible = str(admin.get("email") or "").strip() or "Администратор системы" responsible = str(admin.get("email") or "").strip() or "Администратор системы"
if {"client_id", "client_name", "client_phone"}.intersection(set(changes.keys())): if {"client_id", "client_name", "client_phone"}.intersection(set(changes.keys())):
client = client_for_request_payload_or_400( client = client_for_request_payload_or_400(
@ -227,6 +272,8 @@ def update_request_service(request_id: str, payload: RequestAdminPatch, db: Sess
) )
for key, value in changes.items(): for key, value in changes.items():
setattr(row, key, value) setattr(row, key, value)
new_assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip()
assigned_changed = old_assigned_lawyer_id != new_assigned_lawyer_id
if status_changed: if status_changed:
next_status = str(changes.get("status_code") or "") next_status = str(changes.get("status_code") or "")
important_date_at = row.important_date_at important_date_at = row.important_date_at
@ -260,6 +307,24 @@ def update_request_service(request_id: str, payload: RequestAdminPatch, db: Sess
), ),
responsible=responsible, responsible=responsible,
) )
if actor_role == "ADMIN" and assigned_changed and new_assigned_lawyer_id:
assignment_event_type = NOTIFICATION_EVENT_REASSIGNMENT if old_assigned_lawyer_id else NOTIFICATION_EVENT_ASSIGNMENT
marker_event_type = EVENT_REASSIGNMENT if old_assigned_lawyer_id else EVENT_ASSIGNMENT
mark_unread_for_client(row, marker_event_type)
mark_unread_for_lawyer(row, marker_event_type)
notify_request_event(
db,
request=row,
event_type=assignment_event_type,
actor_role="ADMIN",
actor_admin_user_id=admin.get("sub"),
body=(
f"Назначен юрист: {new_assigned_lawyer_id}"
if not old_assigned_lawyer_id
else f"Переназначено: {old_assigned_lawyer_id} -> {new_assigned_lawyer_id}"
),
responsible=responsible,
)
try: try:
db.add(row) db.add(row)
db.commit() db.commit()
@ -388,6 +453,20 @@ def claim_request_service(request_id: str, db: Session, admin: dict) -> dict[str
if row is None: if row is None:
raise HTTPException(status_code=404, detail="Заявка не найдена") raise HTTPException(status_code=404, detail="Заявка не найдена")
mark_unread_for_client(row, EVENT_ASSIGNMENT)
notify_request_event(
db,
request=row,
event_type=NOTIFICATION_EVENT_ASSIGNMENT,
actor_role="LAWYER",
actor_admin_user_id=str(lawyer_uuid),
body=f"Юрист {str(lawyer.email or lawyer.name or lawyer_uuid)} взял заявку в работу",
responsible=responsible,
)
db.add(row)
db.commit()
db.refresh(row)
return { return {
"status": "claimed", "status": "claimed",
"id": str(row.id), "id": str(row.id),
@ -462,6 +541,21 @@ def reassign_request_service(request_id: str, lawyer_id: str, db: Session, admin
if row is None: if row is None:
raise HTTPException(status_code=404, detail="Заявка не найдена") raise HTTPException(status_code=404, detail="Заявка не найдена")
mark_unread_for_client(row, EVENT_REASSIGNMENT)
mark_unread_for_lawyer(row, EVENT_REASSIGNMENT)
notify_request_event(
db,
request=row,
event_type=NOTIFICATION_EVENT_REASSIGNMENT,
actor_role="ADMIN",
actor_admin_user_id=admin.get("sub"),
body=f"Переназначено: {old_assigned} -> {str(lawyer_uuid)}",
responsible=responsible,
)
db.add(row)
db.commit()
db.refresh(row)
return { return {
"status": "reassigned", "status": "reassigned",
"id": str(row.id), "id": str(row.id),

View file

@ -2,11 +2,13 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from uuid import UUID
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.notification import Notification
from app.models.request import Request from app.models.request import Request
from app.models.status import Status from app.models.status import Status
from app.models.status_group import StatusGroup from app.models.status_group import StatusGroup
@ -76,13 +78,31 @@ def apply_request_special_filters(
raise HTTPException(status_code=400, detail=f'Оператор "{op}" не поддерживается для фильтра "{field}"') raise HTTPException(status_code=400, detail=f'Оператор "{op}" не поддерживается для фильтра "{field}"')
expected = coerce_request_bool_filter_or_400(clause.value) expected = coerce_request_bool_filter_or_400(clause.value)
if field == "has_unread_updates": if field == "has_unread_updates":
actor_expr = None
try:
actor_uuid = UUID(str(actor_id or "").strip())
except ValueError:
actor_uuid = None
if actor_uuid is not None:
actor_expr = Request.id.in_(
db.query(Notification.request_id).filter(
Notification.recipient_type == "ADMIN_USER",
Notification.recipient_admin_user_id == actor_uuid,
Notification.is_read.is_(False),
Notification.request_id.is_not(None),
)
)
if role == "LAWYER": if role == "LAWYER":
expr = Request.lawyer_has_unread_updates.is_(True) expr = Request.lawyer_has_unread_updates.is_(True)
if actor_expr is not None:
expr = or_(expr, actor_expr)
else: else:
expr = or_( expr = or_(
Request.lawyer_has_unread_updates.is_(True), Request.lawyer_has_unread_updates.is_(True),
Request.client_has_unread_updates.is_(True), Request.client_has_unread_updates.is_(True),
) )
if actor_expr is not None:
expr = or_(expr, actor_expr)
elif field == "deadline_alert": elif field == "deadline_alert":
now_utc = datetime.now(timezone.utc) now_utc = datetime.now(timezone.utc)
next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1) next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)

View file

@ -1,5 +1,5 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat, test_utils, system from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, test_utils, system
router = APIRouter() router = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"]) router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
@ -11,7 +11,6 @@ router.include_router(uploads.router, prefix="/uploads", tags=["AdminFiles"])
router.include_router(metrics.router, prefix="/metrics", tags=["AdminMetrics"]) router.include_router(metrics.router, prefix="/metrics", tags=["AdminMetrics"])
router.include_router(notifications.router, prefix="/notifications", tags=["AdminNotifications"]) router.include_router(notifications.router, prefix="/notifications", tags=["AdminNotifications"])
router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"]) router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"])
router.include_router(chat.router, prefix="/chat", tags=["AdminChat"])
router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"]) router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"])
router.include_router(test_utils.router, prefix="/test-utils", tags=["AdminTestUtils"]) router.include_router(test_utils.router, prefix="/test-utils", tags=["AdminTestUtils"])
router.include_router(system.router, prefix="/system", tags=["AdminSystem"]) router.include_router(system.router, prefix="/system", tags=["AdminSystem"])

View file

@ -13,14 +13,15 @@ from app.models.request import Request
from app.models.request_data_requirement import RequestDataRequirement from app.models.request_data_requirement import RequestDataRequirement
from app.schemas.public import PublicMessageCreate from app.schemas.public import PublicMessageCreate
from app.services.chat_presence import list_typing_presence, set_typing_presence from app.services.chat_presence import list_typing_presence, set_typing_presence
from app.services.chat_service import ( from app.services.notifications import EVENT_REQUEST_DATA as NOTIFICATION_EVENT_REQUEST_DATA, notify_request_event, unread_client_summary
from app.services.chat_secure_service import (
create_client_message, create_client_message,
get_chat_activity_summary, get_chat_activity_summary,
list_messages_for_request, list_messages_for_request,
serialize_message, serialize_message,
serialize_messages_for_request, serialize_messages_for_request,
) )
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_lawyer from app.services.request_read_markers import EVENT_REQUEST_DATA, mark_unread_for_lawyer
router = APIRouter() router = APIRouter()
@ -172,6 +173,11 @@ def get_live_chat_state_by_track(
"latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))), "latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))),
"latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))), "latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))),
"typing": typing_rows, "typing": typing_rows,
"unread": unread_client_summary(
db,
track_number=req.track_number,
request_id=req.id,
),
} }
@ -312,8 +318,16 @@ def save_data_request_values(
updated += 1 updated += 1
if updated: if updated:
mark_unread_for_lawyer(req, EVENT_MESSAGE) mark_unread_for_lawyer(req, EVENT_REQUEST_DATA)
req.responsible = "Клиент" req.responsible = "Клиент"
notify_request_event(
db,
request=req,
event_type=NOTIFICATION_EVENT_REQUEST_DATA,
actor_role="CLIENT",
body=f"Клиент обновил дополнительные данные ({updated})",
responsible="Клиент",
)
db.add(req) db.add(req)
db.commit() db.commit()
else: else:

View file

@ -6,6 +6,7 @@ from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
@ -19,18 +20,20 @@ from app.models.client import Client
from app.models.invoice import Invoice from app.models.invoice import Invoice
from app.models.message import Message from app.models.message import Message
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.notification import Notification
from app.models.request import Request from app.models.request import Request
from app.models.request_service_request import RequestServiceRequest from app.models.request_service_request import RequestServiceRequest
from app.models.status_history import StatusHistory from app.models.status_history import StatusHistory
from app.models.topic import Topic from app.models.topic import Topic
from app.services.invoice_crypto import decrypt_requisites from app.services.invoice_crypto import decrypt_requisites
from app.services.invoice_pdf import build_invoice_pdf_bytes from app.services.invoice_pdf import build_invoice_pdf_bytes
from app.services.chat_service import create_client_message, list_messages_for_request from app.services.chat_secure_service import create_client_message, list_messages_for_request
from app.services.notifications import ( from app.services.notifications import (
get_client_notification, get_client_notification,
list_client_notifications, list_client_notifications,
mark_client_notifications_read, mark_client_notifications_read,
serialize_notification, serialize_notification,
unread_client_summary,
) )
from app.services.request_read_markers import clear_unread_for_client from app.services.request_read_markers import clear_unread_for_client
from app.services.request_templates import validate_required_topic_fields_or_400 from app.services.request_templates import validate_required_topic_fields_or_400
@ -241,6 +244,34 @@ def list_my_requests(
query = query.filter(Request.client_phone == normalized_phone) query = query.filter(Request.client_phone == normalized_phone)
rows = query.order_by(Request.updated_at.desc(), Request.created_at.desc(), Request.id.desc()).all() rows = query.order_by(Request.updated_at.desc(), Request.created_at.desc(), Request.id.desc()).all()
row_ids = [row.id for row in rows if row and row.id]
unread_by_request: dict[str, dict[str, object]] = {}
if row_ids:
try:
notif_rows = (
db.query(Notification.request_id, Notification.event_type, func.count(Notification.id))
.filter(
Notification.recipient_type == "CLIENT",
Notification.is_read.is_(False),
Notification.request_id.in_(row_ids),
)
.group_by(Notification.request_id, Notification.event_type)
.all()
)
except SQLAlchemyError:
notif_rows = []
for request_id, event_type, count in notif_rows:
request_key = str(request_id or "")
if not request_key:
continue
bucket = unread_by_request.setdefault(request_key, {"total": 0, "by_event": {}})
event_key = str(event_type or "").strip().upper()
event_count = int(count or 0)
if event_key:
by_event = bucket["by_event"] if isinstance(bucket.get("by_event"), dict) else {}
by_event[event_key] = int(by_event.get(event_key, 0)) + event_count
bucket["by_event"] = by_event
bucket["total"] = int(bucket.get("total", 0)) + event_count
return { return {
"rows": [ "rows": [
{ {
@ -250,6 +281,8 @@ def list_my_requests(
"status_code": row.status_code, "status_code": row.status_code,
"client_has_unread_updates": bool(row.client_has_unread_updates), "client_has_unread_updates": bool(row.client_has_unread_updates),
"client_unread_event_type": row.client_unread_event_type, "client_unread_event_type": row.client_unread_event_type,
"viewer_unread_total": int((unread_by_request.get(str(row.id)) or {}).get("total", 0)),
"viewer_unread_by_event": dict((unread_by_request.get(str(row.id)) or {}).get("by_event", {})),
"created_at": _to_iso(row.created_at), "created_at": _to_iso(row.created_at),
"updated_at": _to_iso(row.updated_at), "updated_at": _to_iso(row.updated_at),
} }
@ -302,6 +335,12 @@ def get_request_by_track(
db.commit() db.commit()
db.refresh(req) db.refresh(req)
unread_after_open = unread_client_summary(
db,
track_number=req.track_number,
request_id=req.id,
)
return { return {
"id": str(req.id), "id": str(req.id),
"client_id": str(req.client_id) if req.client_id else None, "client_id": str(req.client_id) if req.client_id else None,
@ -324,6 +363,8 @@ def get_request_by_track(
"client_unread_event_type": req.client_unread_event_type, "client_unread_event_type": req.client_unread_event_type,
"lawyer_has_unread_updates": req.lawyer_has_unread_updates, "lawyer_has_unread_updates": req.lawyer_has_unread_updates,
"lawyer_unread_event_type": req.lawyer_unread_event_type, "lawyer_unread_event_type": req.lawyer_unread_event_type,
"viewer_unread_total": int(unread_after_open.get("total") or 0),
"viewer_unread_by_event": dict(unread_after_open.get("by_event") or {}),
"created_at": _to_iso(req.created_at), "created_at": _to_iso(req.created_at),
"updated_at": _to_iso(req.updated_at), "updated_at": _to_iso(req.updated_at),
} }

View file

@ -1,5 +1,5 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.public import requests, otp, quotes, uploads, chat, featured_staff from app.api.public import requests, otp, quotes, uploads, featured_staff
router = APIRouter() router = APIRouter()
router.include_router(requests.router, prefix="/requests", tags=["Public"]) router.include_router(requests.router, prefix="/requests", tags=["Public"])
@ -7,4 +7,3 @@ router.include_router(otp.router, prefix="/otp", tags=["Public"])
router.include_router(quotes.router, prefix="/quotes", tags=["Public"]) router.include_router(quotes.router, prefix="/quotes", tags=["Public"])
router.include_router(featured_staff.router, prefix="/featured-staff", tags=["Public"]) router.include_router(featured_staff.router, prefix="/featured-staff", tags=["Public"])
router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"]) router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"])
router.include_router(chat.router, prefix="/chat", tags=["PublicChat"])

31
app/chat_main.py Normal file
View file

@ -0,0 +1,31 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api.admin.chat import router as admin_chat_router
from app.api.public.chat import router as public_chat_router
from app.core.config import settings
from app.core.http_hardening import install_http_hardening
app = FastAPI(title=f"{settings.APP_NAME}-chat", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
install_http_hardening(app)
app.include_router(public_chat_router, prefix="/api/public/chat")
app.include_router(admin_chat_router, prefix="/api/admin/chat")
@app.get("/", include_in_schema=False)
def landing():
return JSONResponse({"service": f"{settings.APP_NAME}-chat", "status": "ok"})
@app.get("/health")
def health():
return {"status": "ok"}

View file

@ -32,6 +32,7 @@ class Settings(BaseSettings):
SMSAERO_API_KEY: str = "" SMSAERO_API_KEY: str = ""
OTP_SMS_TEMPLATE: str = "Your verification code: {code}" OTP_SMS_TEMPLATE: str = "Your verification code: {code}"
DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption" DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption"
CHAT_ENCRYPTION_SECRET: str = ""
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300 OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
OTP_SEND_RATE_LIMIT: int = 8 OTP_SEND_RATE_LIMIT: int = 8
OTP_VERIFY_RATE_LIMIT: int = 20 OTP_VERIFY_RATE_LIMIT: int = 20

17
app/db/encrypted_types.py Normal file
View file

@ -0,0 +1,17 @@
from __future__ import annotations
from sqlalchemy import Text
from sqlalchemy.types import TypeDecorator
from app.services.chat_crypto import decrypt_message_body, encrypt_message_body
class EncryptedChatText(TypeDecorator):
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect):
return encrypt_message_body(value)
def process_result_value(self, value, dialect):
return decrypt_message_body(value)

View file

@ -1,8 +1,9 @@
import uuid import uuid
from sqlalchemy import String, Text, Boolean from sqlalchemy import String, Boolean
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from app.db.session import Base from app.db.session import Base
from app.db.encrypted_types import EncryptedChatText
from app.models.common import UUIDMixin, TimestampMixin from app.models.common import UUIDMixin, TimestampMixin
class Message(Base, UUIDMixin, TimestampMixin): class Message(Base, UUIDMixin, TimestampMixin):
@ -10,5 +11,5 @@ class Message(Base, UUIDMixin, TimestampMixin):
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) 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_type: Mapped[str] = mapped_column(String(20), nullable=False) # CLIENT|LAWYER|SYSTEM
author_name: Mapped[str | None] = mapped_column(String(200), nullable=True) author_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
body: Mapped[str | None] = mapped_column(Text, nullable=True) body: Mapped[str | None] = mapped_column(EncryptedChatText(), nullable=True)
immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

View file

@ -0,0 +1,83 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
from app.core.config import settings
_VERSION = b"v1"
_PREFIX = "chatenc:v1:"
def _encryption_secret() -> str:
chat_secret = str(settings.CHAT_ENCRYPTION_SECRET or "").strip()
if chat_secret:
return chat_secret
fallback = str(settings.DATA_ENCRYPTION_SECRET or "").strip()
if fallback:
return fallback
fallback = str(settings.ADMIN_JWT_SECRET or "").strip()
if fallback:
return fallback
fallback = str(settings.PUBLIC_JWT_SECRET or "").strip()
if fallback:
return fallback
raise ValueError("Не задан секрет шифрования чата")
def _key() -> bytes:
return hashlib.sha256(_encryption_secret().encode("utf-8")).digest()
def _xor_bytes(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))
def is_encrypted_message(value: str | None) -> bool:
token = str(value or "").strip()
return token.startswith(_PREFIX)
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):
return text
raw = text.encode("utf-8")
nonce = secrets.token_bytes(16)
stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(raw))
cipher = _xor_bytes(raw, stream)
tag = hmac.new(_key(), _VERSION + nonce + cipher, hashlib.sha256).digest()
token = _VERSION + nonce + tag + cipher
return _PREFIX + base64.urlsafe_b64encode(token).decode("ascii")
def decrypt_message_body(value: str | None) -> str | None:
if value is None:
return None
text = str(value)
if not text:
return text
if not is_encrypted_message(text):
return text
encoded = text[len(_PREFIX) :]
blob = base64.urlsafe_b64decode(encoded.encode("ascii"))
if len(blob) < 2 + 16 + 32:
raise ValueError("Некорректный зашифрованный формат сообщения")
version = blob[:2]
nonce = blob[2:18]
tag = blob[18:50]
cipher = blob[50:]
if version != _VERSION:
raise ValueError("Неподдерживаемая версия шифрования чата")
expected = hmac.new(_key(), version + nonce + cipher, hashlib.sha256).digest()
if not hmac.compare_digest(tag, expected):
raise ValueError("Поврежденные данные сообщения")
stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(cipher))
raw = _xor_bytes(cipher, stream)
return raw.decode("utf-8")

View file

@ -0,0 +1,259 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from sqlalchemy import func
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.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
def _normalize_message_body(body: str | None) -> str:
message_body = str(body or "").strip()
if not message_body:
raise HTTPException(status_code=400, detail='Поле "body" обязательно')
if len(message_body) > MAX_CHAT_MESSAGE_LEN:
raise HTTPException(status_code=400, detail=f'Поле "body" не должно превышать {MAX_CHAT_MESSAGE_LEN} символов')
return message_body.replace("\x00", "")
def list_messages_for_request(db: Session, request_id: Any) -> list[Message]:
return (
db.query(Message)
.filter(Message.request_id == request_id)
.order_by(Message.created_at.asc(), Message.id.asc())
.all()
)
def serialize_message(row: Message) -> 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,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def _truncate_request_data_label(label: str, limit: int = 18) -> str:
text = str(label or "").strip()
if len(text) <= limit:
return text
return text[: max(3, limit - 3)].rstrip() + "..."
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
requirements = (
db.query(RequestDataRequirement)
.filter(
RequestDataRequirement.request_id == request_id,
RequestDataRequirement.request_message_id.in_(message_ids) if message_ids else False,
)
.order_by(
RequestDataRequirement.request_message_id.asc(),
RequestDataRequirement.sort_order.asc(),
RequestDataRequirement.created_at.asc(),
RequestDataRequirement.id.asc(),
)
.all()
if message_ids
else []
)
by_message_id: dict[str, list[RequestDataRequirement]] = {}
for item in requirements:
mid = str(item.request_message_id or "").strip()
if not mid:
continue
by_message_id.setdefault(mid, []).append(item)
file_attachment_ids = []
for item in requirements:
if str(item.field_type or "").lower() != "file":
continue
raw = str(item.value_text or "").strip()
if not raw:
continue
try:
file_attachment_ids.append(raw)
except Exception:
continue
attachment_map: dict[str, Attachment] = {}
if file_attachment_ids:
attachment_rows = db.query(Attachment).filter(Attachment.id.in_(file_attachment_ids)).all()
attachment_map = {str(row.id): row for row in attachment_rows}
out: list[dict[str, Any]] = []
for row in rows:
payload = serialize_message(row)
linked = by_message_id.get(str(row.id), [])
if linked:
linked_sorted = sorted(
linked,
key=lambda req: (
1 if str(req.value_text or "").strip() else 0,
int(req.sort_order or 0),
req.created_at.timestamp() if getattr(req, "created_at", None) else 0,
str(req.id),
),
)
items = []
all_filled = True
for idx, req in enumerate(linked_sorted, start=1):
value_text = str(req.value_text or "").strip()
is_filled = bool(value_text)
if not is_filled:
all_filled = False
items.append(
{
"id": str(req.id),
"index": idx,
"key": req.key,
"label": req.label,
"label_short": _truncate_request_data_label(str(req.label or "")),
"field_type": str(req.field_type or "text"),
"document_name": req.document_name,
"value_text": req.value_text,
"value_file": (
{
"attachment_id": str(attachment_map[value_text].id),
"file_name": attachment_map[value_text].file_name,
"mime_type": attachment_map[value_text].mime_type,
"size_bytes": int(attachment_map[value_text].size_bytes or 0),
"download_url": None,
}
if str(req.field_type or "").lower() == "file" and value_text in attachment_map
else None
),
"is_filled": is_filled,
}
)
payload["message_kind"] = "REQUEST_DATA"
payload["request_data_items"] = items
payload["request_data_all_filled"] = all_filled and bool(items)
payload["body"] = "Запрос"
else:
payload["message_kind"] = "TEXT"
out.append(payload)
return out
def create_client_message(
db: Session,
*,
request: Request,
body: str,
event_type: str = EVENT_MESSAGE,
) -> Message:
message_body = _normalize_message_body(body)
row = Message(
request_id=request.id,
author_type="CLIENT",
author_name=request.client_name,
body=message_body,
responsible="Клиент",
)
normalized_event = str(event_type or EVENT_MESSAGE).strip().upper() or EVENT_MESSAGE
mark_unread_for_lawyer(request, normalized_event)
request.responsible = "Клиент"
notify_request_event(
db,
request=request,
event_type=normalized_event or NOTIFICATION_EVENT_MESSAGE,
actor_role="CLIENT",
body=None,
responsible="Клиент",
)
db.add(row)
db.add(request)
db.commit()
db.refresh(row)
return row
def create_admin_or_lawyer_message(
db: Session,
*,
request: Request,
body: str,
actor_role: str,
actor_name: str,
actor_admin_user_id: str | None = None,
event_type: str = EVENT_MESSAGE,
) -> Message:
message_body = _normalize_message_body(body)
normalized_role = str(actor_role or "").strip().upper()
if normalized_role not in {"ADMIN", "LAWYER", "CURATOR"}:
raise HTTPException(status_code=400, detail="Некорректная роль автора сообщения")
author_type = "LAWYER" if normalized_role in {"LAWYER", "CURATOR"} else "SYSTEM"
responsible = str(actor_name or "").strip() or "Администратор системы"
row = Message(
request_id=request.id,
author_type=author_type,
author_name=str(actor_name or "").strip() or author_type,
body=message_body,
responsible=responsible,
)
normalized_event = str(event_type or EVENT_MESSAGE).strip().upper() or EVENT_MESSAGE
mark_unread_for_client(request, normalized_event)
request.responsible = responsible
notify_request_event(
db,
request=request,
event_type=normalized_event or NOTIFICATION_EVENT_MESSAGE,
actor_role=normalized_role,
actor_admin_user_id=actor_admin_user_id,
body=None,
responsible=responsible,
)
db.add(row)
db.add(request)
db.commit()
db.refresh(row)
return row
def get_chat_activity_summary(db: Session, request_id: Any) -> dict[str, Any]:
message_count, latest_message_at = (
db.query(
func.count(Message.id),
func.max(func.coalesce(Message.updated_at, Message.created_at)),
)
.filter(Message.request_id == request_id)
.one()
)
attachment_count, latest_attachment_at = (
db.query(
func.count(Attachment.id),
func.max(func.coalesce(Attachment.updated_at, Attachment.created_at)),
)
.filter(Attachment.request_id == request_id)
.one()
)
latest_candidates = [value for value in (latest_message_at, latest_attachment_at) if value is not None]
latest_activity_at = max(latest_candidates) if latest_candidates else None
return {
"message_count": int(message_count or 0),
"attachment_count": int(attachment_count or 0),
"latest_message_at": latest_message_at,
"latest_attachment_at": latest_attachment_at,
"latest_activity_at": latest_activity_at,
}

View file

@ -1,243 +1,20 @@
from __future__ import annotations from __future__ import annotations
from typing import Any # Backward-compatible facade: chat domain logic now lives in chat_secure_service.
from app.services.chat_secure_service import (
from fastapi import HTTPException create_admin_or_lawyer_message,
from sqlalchemy import func create_client_message,
from sqlalchemy.orm import Session get_chat_activity_summary,
list_messages_for_request,
from app.models.message import Message serialize_message,
from app.models.attachment import Attachment serialize_messages_for_request,
from app.models.request import Request
from app.models.request_data_requirement import RequestDataRequirement
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
def list_messages_for_request(db: Session, request_id: Any) -> list[Message]:
return (
db.query(Message)
.filter(Message.request_id == request_id)
.order_by(Message.created_at.asc(), Message.id.asc())
.all()
) )
__all__ = [
def serialize_message(row: Message) -> dict[str, Any]: "create_admin_or_lawyer_message",
return { "create_client_message",
"id": str(row.id), "get_chat_activity_summary",
"request_id": str(row.request_id), "list_messages_for_request",
"author_type": row.author_type, "serialize_message",
"author_name": row.author_name, "serialize_messages_for_request",
"body": row.body, ]
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def _truncate_request_data_label(label: str, limit: int = 18) -> str:
text = str(label or "").strip()
if len(text) <= limit:
return text
return text[: max(3, limit - 3)].rstrip() + "..."
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
requirements = (
db.query(RequestDataRequirement)
.filter(
RequestDataRequirement.request_id == request_id,
RequestDataRequirement.request_message_id.in_(message_ids) if message_ids else False,
)
.order_by(
RequestDataRequirement.request_message_id.asc(),
RequestDataRequirement.sort_order.asc(),
RequestDataRequirement.created_at.asc(),
RequestDataRequirement.id.asc(),
)
.all()
if message_ids
else []
)
by_message_id: dict[str, list[RequestDataRequirement]] = {}
for item in requirements:
mid = str(item.request_message_id or "").strip()
if not mid:
continue
by_message_id.setdefault(mid, []).append(item)
file_attachment_ids = []
for item in requirements:
if str(item.field_type or "").lower() != "file":
continue
raw = str(item.value_text or "").strip()
if not raw:
continue
try:
file_attachment_ids.append(raw)
except Exception:
continue
attachment_map: dict[str, Attachment] = {}
if file_attachment_ids:
attachment_rows = db.query(Attachment).filter(Attachment.id.in_(file_attachment_ids)).all()
attachment_map = {str(row.id): row for row in attachment_rows}
out: list[dict[str, Any]] = []
for row in rows:
payload = serialize_message(row)
linked = by_message_id.get(str(row.id), [])
if linked:
linked_sorted = sorted(
linked,
key=lambda req: (
1 if str(req.value_text or "").strip() else 0,
int(req.sort_order or 0),
req.created_at.timestamp() if getattr(req, "created_at", None) else 0,
str(req.id),
),
)
items = []
all_filled = True
for idx, req in enumerate(linked_sorted, start=1):
value_text = str(req.value_text or "").strip()
is_filled = bool(value_text)
if not is_filled:
all_filled = False
items.append(
{
"id": str(req.id),
"index": idx,
"key": req.key,
"label": req.label,
"label_short": _truncate_request_data_label(str(req.label or "")),
"field_type": str(req.field_type or "text"),
"document_name": req.document_name,
"value_text": req.value_text,
"value_file": (
{
"attachment_id": str(attachment_map[value_text].id),
"file_name": attachment_map[value_text].file_name,
"mime_type": attachment_map[value_text].mime_type,
"size_bytes": int(attachment_map[value_text].size_bytes or 0),
"download_url": None,
}
if str(req.field_type or "").lower() == "file" and value_text in attachment_map
else None
),
"is_filled": is_filled,
}
)
payload["message_kind"] = "REQUEST_DATA"
payload["request_data_items"] = items
payload["request_data_all_filled"] = all_filled and bool(items)
payload["body"] = "Запрос"
else:
payload["message_kind"] = "TEXT"
out.append(payload)
return out
def create_client_message(db: Session, *, request: Request, body: str) -> Message:
message_body = str(body or "").strip()
if not message_body:
raise HTTPException(status_code=400, detail='Поле "body" обязательно')
row = Message(
request_id=request.id,
author_type="CLIENT",
author_name=request.client_name,
body=message_body,
responsible="Клиент",
)
mark_unread_for_lawyer(request, EVENT_MESSAGE)
request.responsible = "Клиент"
notify_request_event(
db,
request=request,
event_type=NOTIFICATION_EVENT_MESSAGE,
actor_role="CLIENT",
body=message_body,
responsible="Клиент",
)
db.add(row)
db.add(request)
db.commit()
db.refresh(row)
return row
def create_admin_or_lawyer_message(
db: Session,
*,
request: Request,
body: str,
actor_role: str,
actor_name: str,
actor_admin_user_id: str | None = None,
) -> Message:
message_body = str(body or "").strip()
if not message_body:
raise HTTPException(status_code=400, detail='Поле "body" обязательно')
normalized_role = str(actor_role or "").strip().upper()
if normalized_role not in {"ADMIN", "LAWYER"}:
raise HTTPException(status_code=400, detail="Некорректная роль автора сообщения")
author_type = "LAWYER" if normalized_role == "LAWYER" else "SYSTEM"
responsible = str(actor_name or "").strip() or "Администратор системы"
row = Message(
request_id=request.id,
author_type=author_type,
author_name=str(actor_name or "").strip() or author_type,
body=message_body,
responsible=responsible,
)
mark_unread_for_client(request, EVENT_MESSAGE)
request.responsible = responsible
notify_request_event(
db,
request=request,
event_type=NOTIFICATION_EVENT_MESSAGE,
actor_role=normalized_role,
actor_admin_user_id=actor_admin_user_id,
body=message_body,
responsible=responsible,
)
db.add(row)
db.add(request)
db.commit()
db.refresh(row)
return row
def get_chat_activity_summary(db: Session, request_id: Any) -> dict[str, Any]:
message_count, latest_message_at = (
db.query(
func.count(Message.id),
func.max(func.coalesce(Message.updated_at, Message.created_at)),
)
.filter(Message.request_id == request_id)
.one()
)
attachment_count, latest_attachment_at = (
db.query(
func.count(Attachment.id),
func.max(func.coalesce(Attachment.updated_at, Attachment.created_at)),
)
.filter(Attachment.request_id == request_id)
.one()
)
latest_candidates = [value for value in (latest_message_at, latest_attachment_at) if value is not None]
latest_activity_at = max(latest_candidates) if latest_candidates else None
return {
"message_count": int(message_count or 0),
"attachment_count": int(attachment_count or 0),
"latest_message_at": latest_message_at,
"latest_attachment_at": latest_attachment_at,
"latest_activity_at": latest_activity_at,
}

View file

@ -4,7 +4,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import and_ from sqlalchemy import and_, func
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -20,12 +20,18 @@ EVENT_MESSAGE = "MESSAGE"
EVENT_ATTACHMENT = "ATTACHMENT" EVENT_ATTACHMENT = "ATTACHMENT"
EVENT_STATUS = "STATUS" EVENT_STATUS = "STATUS"
EVENT_SLA_OVERDUE = "SLA_OVERDUE" EVENT_SLA_OVERDUE = "SLA_OVERDUE"
EVENT_REQUEST_DATA = "REQUEST_DATA"
EVENT_ASSIGNMENT = "ASSIGNMENT"
EVENT_REASSIGNMENT = "REASSIGNMENT"
_EVENT_LABELS = { _EVENT_LABELS = {
EVENT_MESSAGE: "Новое сообщение", EVENT_MESSAGE: "Новое сообщение",
EVENT_ATTACHMENT: "Новый файл", EVENT_ATTACHMENT: "Новый файл",
EVENT_STATUS: "Изменен статус", EVENT_STATUS: "Изменен статус",
EVENT_SLA_OVERDUE: "SLA просрочен", EVENT_SLA_OVERDUE: "SLA просрочен",
EVENT_REQUEST_DATA: "Запрос/обновление данных",
EVENT_ASSIGNMENT: "Заявка назначена",
EVENT_REASSIGNMENT: "Заявка переназначена",
} }
@ -231,7 +237,7 @@ def notify_request_event(
if row is not None: if row is not None:
internal_created += 1 internal_created += 1
if event in {EVENT_MESSAGE, EVENT_ATTACHMENT}: if event in {EVENT_MESSAGE, EVENT_ATTACHMENT, EVENT_REQUEST_DATA}:
if actor == "CLIENT": if actor == "CLIENT":
_notify_lawyer_if_any() _notify_lawyer_if_any()
_notify_admins() _notify_admins()
@ -241,6 +247,10 @@ def notify_request_event(
_notify_client() _notify_client()
if actor == "ADMIN": if actor == "ADMIN":
_notify_lawyer_if_any() _notify_lawyer_if_any()
elif event in {EVENT_ASSIGNMENT, EVENT_REASSIGNMENT}:
_notify_client()
_notify_lawyer_if_any()
_notify_admins()
elif event == EVENT_SLA_OVERDUE: elif event == EVENT_SLA_OVERDUE:
_notify_lawyer_if_any() _notify_lawyer_if_any()
_notify_admins() _notify_admins()
@ -434,3 +444,98 @@ def get_client_notification(
) )
.first() .first()
) )
def unread_admin_summary(
db: Session,
*,
admin_user_id: str | uuid.UUID,
request_id: uuid.UUID | None = None,
) -> dict[str, Any]:
admin_uuid = _as_uuid_or_none(admin_user_id)
if admin_uuid is None:
return {"total": 0, "by_event": {}}
query = db.query(Notification.event_type, func.count(Notification.id)).filter(
Notification.recipient_type == RECIPIENT_ADMIN_USER,
Notification.recipient_admin_user_id == admin_uuid,
Notification.is_read.is_(False),
)
if request_id is not None:
query = query.filter(Notification.request_id == request_id)
try:
rows = query.group_by(Notification.event_type).all()
except SQLAlchemyError:
return {"total": 0, "by_event": {}}
by_event = {str(event_type): int(count or 0) for event_type, count in rows if event_type}
total = int(sum(by_event.values()))
return {"total": total, "by_event": by_event}
def unread_client_summary(
db: Session,
*,
track_number: str,
request_id: uuid.UUID | None = None,
) -> dict[str, Any]:
track = _normalize_track(track_number)
if not track:
return {"total": 0, "by_event": {}}
query = db.query(Notification.event_type, func.count(Notification.id)).filter(
Notification.recipient_type == RECIPIENT_CLIENT,
Notification.recipient_track_number == track,
Notification.is_read.is_(False),
)
if request_id is not None:
query = query.filter(Notification.request_id == request_id)
try:
rows = query.group_by(Notification.event_type).all()
except SQLAlchemyError:
return {"total": 0, "by_event": {}}
by_event = {str(event_type): int(count or 0) for event_type, count in rows if event_type}
total = int(sum(by_event.values()))
return {"total": total, "by_event": by_event}
def unread_global_summary_for_clients(
db: Session,
*,
request_id: uuid.UUID | None = None,
) -> dict[str, Any]:
query = db.query(Notification.event_type, func.count(Notification.id)).filter(
Notification.recipient_type == RECIPIENT_CLIENT,
Notification.is_read.is_(False),
)
if request_id is not None:
query = query.filter(Notification.request_id == request_id)
try:
rows = query.group_by(Notification.event_type).all()
except SQLAlchemyError:
return {"total": 0, "by_event": {}}
by_event = {str(event_type): int(count or 0) for event_type, count in rows if event_type}
total = int(sum(by_event.values()))
return {"total": total, "by_event": by_event}
def unread_global_summary_for_lawyers(
db: Session,
*,
request_id: uuid.UUID | None = None,
) -> dict[str, Any]:
query = (
db.query(Notification.event_type, func.count(Notification.id))
.join(AdminUser, Notification.recipient_admin_user_id == AdminUser.id)
.filter(
Notification.recipient_type == RECIPIENT_ADMIN_USER,
Notification.is_read.is_(False),
AdminUser.role == "LAWYER",
)
)
if request_id is not None:
query = query.filter(Notification.request_id == request_id)
try:
rows = query.group_by(Notification.event_type).all()
except SQLAlchemyError:
return {"total": 0, "by_event": {}}
by_event = {str(event_type): int(count or 0) for event_type, count in rows if event_type}
total = int(sum(by_event.values()))
return {"total": total, "by_event": by_event}

View file

@ -5,6 +5,9 @@ from app.models.request import Request
EVENT_MESSAGE = "MESSAGE" EVENT_MESSAGE = "MESSAGE"
EVENT_ATTACHMENT = "ATTACHMENT" EVENT_ATTACHMENT = "ATTACHMENT"
EVENT_STATUS = "STATUS" EVENT_STATUS = "STATUS"
EVENT_REQUEST_DATA = "REQUEST_DATA"
EVENT_ASSIGNMENT = "ASSIGNMENT"
EVENT_REASSIGNMENT = "REASSIGNMENT"
def mark_unread_for_client(request: Request, event_type: str) -> None: def mark_unread_for_client(request: Request, event_type: str) -> None:

View file

@ -896,6 +896,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
lawyerLoads: [], lawyerLoads: [],
myUnreadByEvent: {}, myUnreadByEvent: {},
myUnreadTotal: 0, myUnreadTotal: 0,
myUnreadNotificationsTotal: 0,
unreadForClients: 0, unreadForClients: 0,
unreadForLawyers: 0, unreadForLawyers: 0,
serviceRequestUnreadTotal: 0, serviceRequestUnreadTotal: 0,
@ -1762,7 +1763,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
{ label: "Мои заявки", value: data.assigned_total ?? 0 }, { label: "Мои заявки", value: data.assigned_total ?? 0 },
{ label: "Мои активные", value: data.active_assigned_total ?? 0 }, { label: "Мои активные", value: data.active_assigned_total ?? 0 },
{ label: "Неназначенные", value: data.unassigned_total ?? 0 }, { label: "Неназначенные", value: data.unassigned_total ?? 0 },
{ label: "Мои непрочитанные", value: data.my_unread_updates ?? 0 }, { label: "Мои непрочитанные", value: data.my_unread_notifications_total ?? data.my_unread_updates ?? 0 },
{ label: "Просрочено SLA", value: data.sla_overdue ?? 0 }, { label: "Просрочено SLA", value: data.sla_overdue ?? 0 },
] ]
: [ : [
@ -1770,6 +1771,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
{ label: "Назначенные", value: data.assigned_total ?? 0 }, { label: "Назначенные", value: data.assigned_total ?? 0 },
{ label: "Неназначенные", value: data.unassigned_total ?? 0 }, { label: "Неназначенные", value: data.unassigned_total ?? 0 },
{ label: "Просрочено SLA", value: data.sla_overdue ?? 0 }, { label: "Просрочено SLA", value: data.sla_overdue ?? 0 },
{ label: "Мои непрочитанные", value: data.my_unread_notifications_total ?? data.my_unread_updates ?? 0 },
{ label: "Выручка (мес.)", value: Number(data.month_revenue ?? 0).toFixed(2) }, { label: "Выручка (мес.)", value: Number(data.month_revenue ?? 0).toFixed(2) },
{ label: "Расходы (мес.)", value: Number(data.month_expenses ?? 0).toFixed(2) }, { label: "Расходы (мес.)", value: Number(data.month_expenses ?? 0).toFixed(2) },
{ label: "Непрочитано юристами", value: data.unread_for_lawyers ?? 0 }, { label: "Непрочитано юристами", value: data.unread_for_lawyers ?? 0 },
@ -1786,8 +1788,9 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
lawyerLoads: data.lawyer_loads || [], lawyerLoads: data.lawyer_loads || [],
myUnreadByEvent: data.my_unread_by_event || {}, myUnreadByEvent: data.my_unread_by_event || {},
myUnreadTotal: Number(data.my_unread_updates || 0), myUnreadTotal: Number(data.my_unread_updates || 0),
unreadForClients: Number(data.unread_for_clients || 0), myUnreadNotificationsTotal: Number(data.my_unread_notifications_total || data.my_unread_updates || 0),
unreadForLawyers: Number(data.unread_for_lawyers || 0), unreadForClients: Number(data.unread_for_clients_notifications_total || data.unread_for_clients || 0),
unreadForLawyers: Number(data.unread_for_lawyers_notifications_total || data.unread_for_lawyers || 0),
serviceRequestUnreadTotal: Number(data.service_request_unread_total || 0), serviceRequestUnreadTotal: Number(data.service_request_unread_total || 0),
deadlineAlertTotal: Number(data.deadline_alert_total || 0), deadlineAlertTotal: Number(data.deadline_alert_total || 0),
monthRevenue: Number(data.month_revenue || 0), monthRevenue: Number(data.month_revenue || 0),
@ -2629,6 +2632,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
lawyerLoads: [], lawyerLoads: [],
myUnreadByEvent: {}, myUnreadByEvent: {},
myUnreadTotal: 0, myUnreadTotal: 0,
myUnreadNotificationsTotal: 0,
unreadForClients: 0, unreadForClients: 0,
unreadForLawyers: 0, unreadForLawyers: 0,
serviceRequestUnreadTotal: 0, serviceRequestUnreadTotal: 0,
@ -2789,9 +2793,11 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
const topbarUnreadCount = useMemo(() => { const topbarUnreadCount = useMemo(() => {
const roleCode = String(role || "").toUpperCase(); const roleCode = String(role || "").toUpperCase();
if (roleCode === "LAWYER") return Number(dashboardData.myUnreadTotal || 0); if (roleCode === "LAWYER" || roleCode === "ADMIN" || roleCode === "CURATOR") {
return Number(dashboardData.myUnreadNotificationsTotal || dashboardData.myUnreadTotal || 0);
}
return Number(dashboardData.unreadForClients || 0) + Number(dashboardData.unreadForLawyers || 0); return Number(dashboardData.unreadForClients || 0) + Number(dashboardData.unreadForLawyers || 0);
}, [dashboardData.myUnreadTotal, dashboardData.unreadForClients, dashboardData.unreadForLawyers, role]); }, [dashboardData.myUnreadNotificationsTotal, dashboardData.myUnreadTotal, dashboardData.unreadForClients, dashboardData.unreadForLawyers, role]);
const topbarDeadlineAlertCount = useMemo(() => Number(dashboardData.deadlineAlertTotal || 0), [dashboardData.deadlineAlertTotal]); const topbarDeadlineAlertCount = useMemo(() => Number(dashboardData.deadlineAlertTotal || 0), [dashboardData.deadlineAlertTotal]);
const topbarServiceRequestUnreadCount = useMemo( const topbarServiceRequestUnreadCount = useMemo(

View file

@ -1767,7 +1767,7 @@ export function RequestWorkspace({
)} )}
</div> </div>
{clientDataModal.error ? <div className="status error">{clientDataModal.error}</div> : null} {clientDataModal.error ? <div className="status error">{clientDataModal.error}</div> : null}
<div className={"status" + (clientDataModal.status ? " ok" : "")} id={idMap.dataRequestStatus}> <div className={"request-data-status" + (clientDataModal.status ? " ok" : "")} id={idMap.dataRequestStatus}>
{clientDataModal.status || ""} {clientDataModal.status || ""}
</div> </div>
<div className="modal-actions modal-actions-right"> <div className="modal-actions modal-actions-right">

View file

@ -4,12 +4,30 @@ import { fmtDate, statusLabel } from "../../shared/utils.js";
function renderRequestUpdatesCell(row, role) { function renderRequestUpdatesCell(row, role) {
const hasServiceRequestUnread = Boolean(row?.has_service_requests_unread); const hasServiceRequestUnread = Boolean(row?.has_service_requests_unread);
const serviceRequestCount = Number(row?.service_requests_unread_count || 0); const serviceRequestCount = Number(row?.service_requests_unread_count || 0);
const viewerUnreadTotal = Number(row?.viewer_unread_total || 0);
const viewerUnreadByEvent = row?.viewer_unread_by_event && typeof row.viewer_unread_by_event === "object" ? row.viewer_unread_by_event : {};
const viewerUnreadLabel =
viewerUnreadTotal > 0
? Object.entries(viewerUnreadByEvent)
.map(([eventType, count]) => {
const code = String(eventType || "").toUpperCase();
const label = REQUEST_UPDATE_EVENT_LABELS[code] || code.toLowerCase();
return label + ": " + String(count || 0);
})
.join(", ")
: "";
if (role === "LAWYER") { if (role === "LAWYER") {
const has = Boolean(row.lawyer_has_unread_updates); const has = Boolean(row.lawyer_has_unread_updates);
const eventType = String(row.lawyer_unread_event_type || "").toUpperCase(); const eventType = String(row.lawyer_unread_event_type || "").toUpperCase();
if (!has && !hasServiceRequestUnread) return <span className="request-update-empty">нет</span>; if (!has && !hasServiceRequestUnread && !viewerUnreadTotal) return <span className="request-update-empty">нет</span>;
return ( return (
<span className="request-updates-stack"> <span className="request-updates-stack">
{viewerUnreadTotal > 0 ? (
<span className="request-update-chip" title={"Мои непрочитанные: " + (viewerUnreadLabel || String(viewerUnreadTotal))}>
<span className="request-update-dot" />
{"Мне: " + String(viewerUnreadTotal)}
</span>
) : null}
{has ? ( {has ? (
<span className="request-update-chip" title={"Есть непрочитанное обновление: " + (REQUEST_UPDATE_EVENT_LABELS[eventType] || eventType.toLowerCase())}> <span className="request-update-chip" title={"Есть непрочитанное обновление: " + (REQUEST_UPDATE_EVENT_LABELS[eventType] || eventType.toLowerCase())}>
<span className="request-update-dot" /> <span className="request-update-dot" />
@ -31,9 +49,15 @@ function renderRequestUpdatesCell(row, role) {
const lawyerHas = Boolean(row.lawyer_has_unread_updates); const lawyerHas = Boolean(row.lawyer_has_unread_updates);
const lawyerType = String(row.lawyer_unread_event_type || "").toUpperCase(); const lawyerType = String(row.lawyer_unread_event_type || "").toUpperCase();
if (!clientHas && !lawyerHas && !hasServiceRequestUnread) return <span className="request-update-empty">нет</span>; if (!clientHas && !lawyerHas && !hasServiceRequestUnread && !viewerUnreadTotal) return <span className="request-update-empty">нет</span>;
return ( return (
<span className="request-updates-stack"> <span className="request-updates-stack">
{viewerUnreadTotal > 0 ? (
<span className="request-update-chip" title={"Мои непрочитанные: " + (viewerUnreadLabel || String(viewerUnreadTotal))}>
<span className="request-update-dot" />
{"Мне: " + String(viewerUnreadTotal)}
</span>
) : null}
{clientHas ? ( {clientHas ? (
<span className="request-update-chip" title={"Клиенту: " + (REQUEST_UPDATE_EVENT_LABELS[clientType] || clientType.toLowerCase())}> <span className="request-update-chip" title={"Клиенту: " + (REQUEST_UPDATE_EVENT_LABELS[clientType] || clientType.toLowerCase())}>
<span className="request-update-dot" /> <span className="request-update-dot" />

View file

@ -44,6 +44,9 @@ export const STATUS_KIND_LABELS = {
export const REQUEST_UPDATE_EVENT_LABELS = { export const REQUEST_UPDATE_EVENT_LABELS = {
MESSAGE: "сообщение", MESSAGE: "сообщение",
ATTACHMENT: "файл", ATTACHMENT: "файл",
REQUEST_DATA: "данные",
ASSIGNMENT: "назначение",
REASSIGNMENT: "переназначение",
STATUS: "статус", STATUS: "статус",
}; };

View file

@ -809,7 +809,10 @@ import { detectAttachmentPreviewKind, fmtShortDateTime } from "./admin/shared/ut
> >
{requestsList.map((row) => ( {requestsList.map((row) => (
<option value={String(row.track_number || "")} key={String(row.id || row.track_number || "")}> <option value={String(row.track_number || "")} key={String(row.id || row.track_number || "")}>
{String(row.track_number || "Без номера") + " • " + String(row.status_code || "-")} {String(row.track_number || "Без номера") +
" • " +
String(row.status_code || "-") +
(Number(row?.viewer_unread_total || 0) > 0 ? " • непрочитано: " + String(row.viewer_unread_total) : "")}
</option> </option>
))} ))}
</select> </select>

Binary file not shown.

View file

@ -46,6 +46,18 @@ docker compose exec -T backend python -m app.data.cleanup_test_artifacts
docker compose exec -T backend python -m app.data.manual_test_seed docker compose exec -T backend python -m app.data.manual_test_seed
``` ```
Доступы и список тестовых заявок сохраняются в `/Users/tronosfera/Develop/Law/context/15_manual_test_access.md`. Доступы и список тестовых заявок сохраняются в `/Users/tronosfera/Develop/Law/context/15_manual_test_access.md`.
8. Проверка health всех контейнеров после деплоя/рестарта:
```bash
docker compose up -d
docker compose ps
curl -fsS http://localhost:8081/health
curl -fsS http://localhost:8081/chat-health
```
9. Оперативная проверка и alert-код для `backend/chat-service` (под cron/CI):
```bash
./scripts/ops/check_chat_health.sh
echo $? # 0=OK, >0=ALERT
```
## Матрица проверок по задачам ## Матрица проверок по задачам
| ID | Что проверяем | Где тесты | Как запускать | | ID | Что проверяем | Где тесты | Как запускать |
@ -82,7 +94,7 @@ docker compose exec -T backend python -m app.data.manual_test_seed
| P30 | Отдельная страница работы с заявкой клиента | новые e2e для client workspace route + `tests/test_public_cabinet.py` | добавить e2e route-flow + прогон `test_public_cabinet` | | P30 | Отдельная страница работы с заявкой клиента | новые e2e для client workspace route + `tests/test_public_cabinet.py` | добавить e2e route-flow + прогон `test_public_cabinet` |
| P31 | Вход клиента через phone+OTP модалку | новые e2e OTP modal flow + `tests/test_otp_rate_limit.py`, `tests/test_public_requests.py` | e2e + backend OTP тесты | | P31 | Вход клиента через phone+OTP модалку | новые e2e OTP modal flow + `tests/test_otp_rate_limit.py`, `tests/test_public_requests.py` | e2e + backend OTP тесты |
| P32 | Переключение между заявками клиента | новые e2e multi-request flow + `tests/test_public_cabinet.py` | e2e multi-request + backend regression | | P32 | Переключение между заявками клиента | новые e2e multi-request flow + `tests/test_public_cabinet.py` | e2e multi-request + backend regression |
| P33 | Чат в отдельном сервисе | `tests/test_public_cabinet.py`, `tests/admin/*` (chat service cases) + UI smoke (`client.js`, `admin.jsx`) | `docker compose run --rm backend python -m unittest tests.test_public_cabinet -v` + `tests/admin/*` (discover) + фронт-сборка admin entrypoint | | P33 | Чат в отдельном сервисе | `tests/test_public_cabinet.py`, `tests/admin/*` (chat service cases) + UI smoke (`client.js`, `admin.jsx`) + container health | `docker compose run --rm backend python -m unittest tests.test_public_cabinet -v` + `tests/admin/*` (discover) + фронт-сборка admin entrypoint + базовые команды 8-9 |
| P34 | Ненавязчивые цитаты в блоке «Первая консультация» | UI e2e/smoke лендинга | визуальная регрессия лендинга + Playwright public smoke | | P34 | Ненавязчивые цитаты в блоке «Первая консультация» | UI e2e/smoke лендинга | визуальная регрессия лендинга + Playwright public smoke |
| P35 | Предпросмотр документов | `tests/test_uploads_s3.py` (`test_public_attachment_object_preview_returns_inline_response`) + Playwright (`e2e/tests/public_client_flow.spec.js`, `e2e/tests/lawyer_role_flow.spec.js`) | `docker compose run --rm backend python -m unittest tests.test_uploads_s3 -v` + Playwright UI-прогон preview в клиенте и во вкладке работы с заявкой юриста/админа через сервис `e2e` | | P35 | Предпросмотр документов | `tests/test_uploads_s3.py` (`test_public_attachment_object_preview_returns_inline_response`) + Playwright (`e2e/tests/public_client_flow.spec.js`, `e2e/tests/lawyer_role_flow.spec.js`) | `docker compose run --rm backend python -m unittest tests.test_uploads_s3 -v` + Playwright UI-прогон preview в клиенте и во вкладке работы с заявкой юриста/админа через сервис `e2e` |
| P36 | Навигация в админку и редиректы | `e2e/tests/admin_entry_flow.spec.js` + redirect checks | Playwright `admin_entry_flow` + `curl -I -H 'Host: localhost:8081' http://localhost:8081/admin` (ожидается `302` и `Location: /admin.html`) + `curl -I http://localhost:8081/admin.html` | | P36 | Навигация в админку и редиректы | `e2e/tests/admin_entry_flow.spec.js` + redirect checks | Playwright `admin_entry_flow` + `curl -I -H 'Host: localhost:8081' http://localhost:8081/admin` (ожидается `302` и `Location: /admin.html`) + `curl -I http://localhost:8081/admin.html` |

View file

@ -0,0 +1,44 @@
# Production deploy (ruakb.ru)
## Цель
Развернуть платформу на сервере `45.150.36.116` c HTTPS на `80/443` для домена `ruakb.ru`.
## Что добавлено
- `docker-compose.prod.yml` — production override:
- добавлен edge proxy (`caddy`) на `80/443`
- отключены внешние порты у внутренних сервисов
- `deploy/caddy/Caddyfile` — TLS (Let's Encrypt) + reverse proxy
- `scripts/ops/deploy_prod.sh` — запуск стека и миграций
## Предусловия
1. DNS:
- `A ruakb.ru -> 45.150.36.116`
- `A www.ruakb.ru -> 45.150.36.116` (опционально)
2. Открыты порты сервера:
- `80/tcp`, `443/tcp`
## Запуск
```bash
cd /opt/law
./scripts/ops/deploy_prod.sh
```
## Проверка
```bash
curl -I http://ruakb.ru
curl -I https://ruakb.ru
curl -fsS https://ruakb.ru/health
curl -fsS https://ruakb.ru/chat-health
```
## Обновление
```bash
git pull
./scripts/ops/deploy_prod.sh
```
## Откат
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml down
# и вернуть предыдущий git tag/commit
```

16
deploy/caddy/Caddyfile Normal file
View file

@ -0,0 +1,16 @@
ruakb.ru, www.ruakb.ru {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
reverse_proxy frontend:80
}
http://45.150.36.116 {
redir https://ruakb.ru{uri} 308
}

34
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,34 @@
services:
edge:
image: caddy:2.8.4-alpine
container_name: law-edge
restart: unless-stopped
depends_on:
frontend:
condition: service_healthy
ports:
- "80:80"
- "443:443"
volumes:
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
frontend:
ports: []
backend:
ports: []
db:
ports: []
redis:
ports: []
minio:
ports: []
volumes:
caddy_data:
caddy_config:

View file

@ -4,7 +4,18 @@ services:
context: . context: .
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
container_name: law-frontend container_name: law-frontend
depends_on: [backend] restart: unless-stopped
depends_on:
backend:
condition: service_healthy
chat-service:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1/health >/dev/null 2>&1 && wget -q -O - http://127.0.0.1/chat-health >/dev/null 2>&1"]
interval: 20s
timeout: 5s
retries: 5
start_period: 20s
ports: ["8081:80", "8080:80"] ports: ["8081:80", "8080:80"]
e2e: e2e:
@ -14,7 +25,9 @@ services:
image: law-e2e-playwright:1.58.2 image: law-e2e-playwright:1.58.2
container_name: law-e2e container_name: law-e2e
working_dir: /src/e2e working_dir: /src/e2e
depends_on: [frontend] depends_on:
frontend:
condition: service_healthy
volumes: volumes:
- .:/src - .:/src
- /src/e2e/node_modules - /src/e2e/node_modules
@ -25,45 +38,102 @@ services:
backend: backend:
build: . build: .
container_name: law-backend container_name: law-backend
restart: unless-stopped
env_file: .env env_file: .env
depends_on: [db, redis, minio] depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=3)\""]
interval: 20s
timeout: 5s
retries: 5
start_period: 25s
ports: ["8002:8000"] ports: ["8002:8000"]
volumes: [".:/app"] volumes: [".:/app"]
chat-service:
build: .
container_name: law-chat-service
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
command: ["uvicorn", "app.chat_main:app", "--host", "0.0.0.0", "--port", "8001"]
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8001/health', timeout=3)\""]
interval: 20s
timeout: 5s
retries: 5
start_period: 25s
volumes: [".:/app"]
worker: worker:
build: . build: .
container_name: law-worker container_name: law-worker
restart: unless-stopped
env_file: .env env_file: .env
depends_on: [db, redis, minio] depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_started
command: ["celery","-A","app.workers.celery_app:celery_app","worker","-Q","notifications,maintenance,uploads","-l","INFO"] command: ["celery","-A","app.workers.celery_app:celery_app","worker","-Q","notifications,maintenance,uploads","-l","INFO"]
volumes: [".:/app"] volumes: [".:/app"]
beat: beat:
build: . build: .
container_name: law-beat container_name: law-beat
restart: unless-stopped
env_file: .env env_file: .env
depends_on: [redis] depends_on:
redis:
condition: service_healthy
command: ["celery","-A","app.workers.celery_app:celery_app","beat","-l","INFO"] command: ["celery","-A","app.workers.celery_app:celery_app","beat","-l","INFO"]
volumes: [".:/app"] volumes: [".:/app"]
db: db:
image: postgres:16 image: postgres:16
container_name: law-db container_name: law-db
restart: unless-stopped
environment: environment:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_DB: legal POSTGRES_DB: legal
ports: ["5432:5432"] ports: ["5432:5432"]
volumes: ["pgdata:/var/lib/postgresql/data"] volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d legal"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
redis: redis:
image: redis:7 image: redis:7
container_name: law-redis container_name: law-redis
restart: unless-stopped
ports: ["6379:6379"] ports: ["6379:6379"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
minio: minio:
image: minio/minio:latest image: minio/minio:latest
container_name: law-minio container_name: law-minio
restart: unless-stopped
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
environment: environment:
MINIO_ROOT_USER: minioadmin MINIO_ROOT_USER: minioadmin

View file

@ -60,6 +60,24 @@ server {
try_files $uri /index.html; try_files $uri /index.html;
} }
location /api/public/chat/ {
proxy_pass http://chat-service:8001/api/public/chat/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/admin/chat/ {
proxy_pass http://chat-service:8001/api/admin/chat/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ { location /api/ {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_http_version 1.1; proxy_http_version 1.1;
@ -83,4 +101,10 @@ server {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
} }
location /chat-health {
proxy_pass http://chat-service:8001/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
} }

View file

@ -0,0 +1,29 @@
#!/usr/bin/env sh
set -eu
BASE_URL="${1:-http://localhost:8081}"
CHAT_HEALTH_URL="${BASE_URL%/}/chat-health"
BACKEND_HEALTH_URL="${BASE_URL%/}/health"
check_http_200() {
url="$1"
code="$(curl -sS -o /dev/null -w "%{http_code}" "$url" || true)"
[ "$code" = "200" ]
}
if ! check_http_200 "$CHAT_HEALTH_URL"; then
echo "[ALERT] chat-service health check failed: $CHAT_HEALTH_URL" >&2
exit 2
fi
if ! check_http_200 "$BACKEND_HEALTH_URL"; then
echo "[ALERT] backend health check failed: $BACKEND_HEALTH_URL" >&2
exit 3
fi
if docker compose ps --format json 2>/dev/null | grep -q '"Health":"unhealthy"'; then
echo "[ALERT] at least one container has unhealthy state" >&2
exit 4
fi
echo "[OK] chat-service and backend are healthy"

25
scripts/ops/deploy_prod.sh Executable file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
if [[ ! -f .env ]]; then
echo "[ERROR] .env not found in $ROOT_DIR"
exit 1
fi
echo "[1/4] Build and start production stack..."
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
echo "[2/4] Apply migrations..."
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec -T backend alembic upgrade head
echo "[3/4] Service status..."
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
echo "[4/4] Smoke checks..."
curl -fsS http://localhost/health >/dev/null
curl -fsS http://localhost/chat-health >/dev/null
echo "Done. Open https://ruakb.ru"

View file

@ -1,4 +1,6 @@
from tests.admin.base import * # noqa: F401,F403 from tests.admin.base import * # noqa: F401,F403
from app.chat_main import app as chat_app
from app.db.session import get_db
from app.services.chat_presence import clear_presence_for_tests from app.services.chat_presence import clear_presence_for_tests
@ -6,8 +8,18 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
clear_presence_for_tests() clear_presence_for_tests()
def override_get_db():
db = self.SessionLocal()
try:
yield db
finally:
db.close()
chat_app.dependency_overrides[get_db] = override_get_db
self.chat_client = TestClient(chat_app)
def tearDown(self): def tearDown(self):
self.chat_client.close()
chat_app.dependency_overrides.clear()
clear_presence_for_tests() clear_presence_for_tests()
super().tearDown() super().tearDown()
@ -396,14 +408,14 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
lawyer_headers = self._auth_headers("LAWYER", email="lawyer.chat.self@example.com", sub=self_id) lawyer_headers = self._auth_headers("LAWYER", email="lawyer.chat.self@example.com", sub=self_id)
admin_headers = self._auth_headers("ADMIN", email="root@example.com") admin_headers = self._auth_headers("ADMIN", email="root@example.com")
own_list = self.client.get(f"/api/admin/chat/requests/{own_id}/messages", headers=lawyer_headers) own_list = self.chat_client.get(f"/api/admin/chat/requests/{own_id}/messages", headers=lawyer_headers)
self.assertEqual(own_list.status_code, 200) self.assertEqual(own_list.status_code, 200)
self.assertEqual(own_list.json()["total"], 1) self.assertEqual(own_list.json()["total"], 1)
foreign_list = self.client.get(f"/api/admin/chat/requests/{foreign_id}/messages", headers=lawyer_headers) foreign_list = self.chat_client.get(f"/api/admin/chat/requests/{foreign_id}/messages", headers=lawyer_headers)
self.assertEqual(foreign_list.status_code, 403) self.assertEqual(foreign_list.status_code, 403)
own_create = self.client.post( own_create = self.chat_client.post(
f"/api/admin/chat/requests/{own_id}/messages", f"/api/admin/chat/requests/{own_id}/messages",
headers=lawyer_headers, headers=lawyer_headers,
json={"body": "Ответ из chat service"}, json={"body": "Ответ из chat service"},
@ -411,14 +423,14 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
self.assertEqual(own_create.status_code, 201) self.assertEqual(own_create.status_code, 201)
self.assertEqual(own_create.json()["author_type"], "LAWYER") self.assertEqual(own_create.json()["author_type"], "LAWYER")
unassigned_create = self.client.post( unassigned_create = self.chat_client.post(
f"/api/admin/chat/requests/{unassigned_id}/messages", f"/api/admin/chat/requests/{unassigned_id}/messages",
headers=lawyer_headers, headers=lawyer_headers,
json={"body": "Нельзя в неназначенную"}, json={"body": "Нельзя в неназначенную"},
) )
self.assertEqual(unassigned_create.status_code, 403) self.assertEqual(unassigned_create.status_code, 403)
admin_create = self.client.post( admin_create = self.chat_client.post(
f"/api/admin/chat/requests/{foreign_id}/messages", f"/api/admin/chat/requests/{foreign_id}/messages",
headers=admin_headers, headers=admin_headers,
json={"body": "Сообщение администратора"}, json={"body": "Сообщение администратора"},
@ -485,10 +497,10 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
lawyer_headers = self._auth_headers("LAWYER", email="lawyer.live.self@example.com", sub=self_id) lawyer_headers = self._auth_headers("LAWYER", email="lawyer.live.self@example.com", sub=self_id)
admin_headers = self._auth_headers("ADMIN", email="root@example.com") admin_headers = self._auth_headers("ADMIN", email="root@example.com")
own_live = self.client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers) own_live = self.chat_client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers)
self.assertEqual(own_live.status_code, 200) self.assertEqual(own_live.status_code, 200)
own_cursor = str(own_live.json().get("cursor") or "") own_cursor = str(own_live.json().get("cursor") or "")
own_live_no_delta = self.client.get( own_live_no_delta = self.chat_client.get(
f"/api/admin/chat/requests/{own_id}/live", f"/api/admin/chat/requests/{own_id}/live",
headers=lawyer_headers, headers=lawyer_headers,
params={"cursor": own_cursor}, params={"cursor": own_cursor},
@ -496,10 +508,10 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
self.assertEqual(own_live_no_delta.status_code, 200) self.assertEqual(own_live_no_delta.status_code, 200)
self.assertFalse(bool(own_live_no_delta.json().get("has_updates"))) self.assertFalse(bool(own_live_no_delta.json().get("has_updates")))
foreign_live = self.client.get(f"/api/admin/chat/requests/{foreign_id}/live", headers=lawyer_headers) foreign_live = self.chat_client.get(f"/api/admin/chat/requests/{foreign_id}/live", headers=lawyer_headers)
self.assertEqual(foreign_live.status_code, 403) self.assertEqual(foreign_live.status_code, 403)
own_typing = self.client.post( own_typing = self.chat_client.post(
f"/api/admin/chat/requests/{own_id}/typing", f"/api/admin/chat/requests/{own_id}/typing",
headers=lawyer_headers, headers=lawyer_headers,
json={"typing": True}, json={"typing": True},
@ -507,14 +519,14 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
self.assertEqual(own_typing.status_code, 200) self.assertEqual(own_typing.status_code, 200)
self.assertTrue(bool(own_typing.json().get("typing"))) self.assertTrue(bool(own_typing.json().get("typing")))
unassigned_typing = self.client.post( unassigned_typing = self.chat_client.post(
f"/api/admin/chat/requests/{unassigned_id}/typing", f"/api/admin/chat/requests/{unassigned_id}/typing",
headers=lawyer_headers, headers=lawyer_headers,
json={"typing": True}, json={"typing": True},
) )
self.assertEqual(unassigned_typing.status_code, 403) self.assertEqual(unassigned_typing.status_code, 403)
admin_typing = self.client.post( admin_typing = self.chat_client.post(
f"/api/admin/chat/requests/{own_id}/typing", f"/api/admin/chat/requests/{own_id}/typing",
headers=admin_headers, headers=admin_headers,
json={"typing": True}, json={"typing": True},
@ -522,7 +534,7 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
self.assertEqual(admin_typing.status_code, 200) self.assertEqual(admin_typing.status_code, 200)
self.assertTrue(bool(admin_typing.json().get("typing"))) self.assertTrue(bool(admin_typing.json().get("typing")))
own_live_with_typing = self.client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers) own_live_with_typing = self.chat_client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers)
self.assertEqual(own_live_with_typing.status_code, 200) self.assertEqual(own_live_with_typing.status_code, 200)
typing_rows = own_live_with_typing.json().get("typing") or [] typing_rows = own_live_with_typing.json().get("typing") or []
self.assertTrue(any(str(item.get("actor_role")) == "ADMIN" for item in typing_rows)) self.assertTrue(any(str(item.get("actor_role")) == "ADMIN" for item in typing_rows))

View file

@ -29,6 +29,7 @@ from app.models.request import Request
from app.models.status import Status from app.models.status import Status
from app.models.status_history import StatusHistory from app.models.status_history import StatusHistory
from app.models.topic_status_transition import TopicStatusTransition from app.models.topic_status_transition import TopicStatusTransition
from app.services.notifications import EVENT_REQUEST_DATA, notify_request_event
from app.workers.tasks import sla as sla_task from app.workers.tasks import sla as sla_task
@ -274,6 +275,123 @@ class NotificationFlowTests(unittest.TestCase):
self.assertEqual(len(rows), 1) self.assertEqual(len(rows), 1)
self.assertEqual(str(rows[0].recipient_admin_user_id), lawyer_id) self.assertEqual(str(rows[0].recipient_admin_user_id), lawyer_id)
def test_admin_reassign_creates_reassignment_notifications_and_unread_markers(self):
with self.SessionLocal() as db:
admin = AdminUser(
role="ADMIN",
name="Админ",
email="root.reassign@example.com",
password_hash="hash",
is_active=True,
)
lawyer_old = AdminUser(
role="LAWYER",
name="Юрист Старый",
email="lawyer.old@example.com",
password_hash="hash",
is_active=True,
)
lawyer_new = AdminUser(
role="LAWYER",
name="Юрист Новый",
email="lawyer.new@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([admin, lawyer_old, lawyer_new])
db.flush()
req = Request(
track_number="TRK-NOTIF-REASSIGN",
client_name="Клиент",
client_phone="+79990000031",
topic_code="civil",
status_code="IN_PROGRESS",
description="reassign notification",
extra_fields={},
assigned_lawyer_id=str(lawyer_old.id),
)
db.add(req)
db.commit()
request_id = str(req.id)
admin_id = str(admin.id)
new_lawyer_id = str(lawyer_new.id)
headers = self._admin_headers(admin_id, "ADMIN", "root.reassign@example.com")
resp = self.client.post(
f"/api/admin/requests/{request_id}/reassign",
headers=headers,
json={"lawyer_id": new_lawyer_id},
)
self.assertEqual(resp.status_code, 200)
with self.SessionLocal() as db:
rows = (
db.query(Notification)
.filter(
Notification.request_id == UUID(request_id),
Notification.event_type == "REASSIGNMENT",
)
.all()
)
self.assertGreaterEqual(len(rows), 2)
self.assertTrue(any(str(row.recipient_track_number or "").upper() == "TRK-NOTIF-REASSIGN" for row in rows))
self.assertTrue(any(str(row.recipient_admin_user_id or "") == new_lawyer_id for row in rows))
req = db.get(Request, UUID(request_id))
self.assertIsNotNone(req)
self.assertTrue(bool(req.client_has_unread_updates))
self.assertEqual(str(req.client_unread_event_type or "").upper(), "REASSIGNMENT")
self.assertTrue(bool(req.lawyer_has_unread_updates))
self.assertEqual(str(req.lawyer_unread_event_type or "").upper(), "REASSIGNMENT")
def test_request_data_event_from_client_notifies_lawyer_and_admin(self):
with self.SessionLocal() as db:
admin = AdminUser(
role="ADMIN",
name="Админ",
email="root.data@example.com",
password_hash="hash",
is_active=True,
)
lawyer = AdminUser(
role="LAWYER",
name="Юрист",
email="lawyer.data@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([admin, lawyer])
db.flush()
req = Request(
track_number="TRK-NOTIF-REQDATA",
client_name="Клиент",
client_phone="+79990000032",
topic_code="civil",
status_code="IN_PROGRESS",
description="request data notification",
extra_fields={},
assigned_lawyer_id=str(lawyer.id),
)
db.add(req)
db.flush()
result = notify_request_event(
db,
request=req,
event_type=EVENT_REQUEST_DATA,
actor_role="CLIENT",
body="Клиент обновил доп. данные",
responsible="Клиент",
send_telegram=False,
)
db.commit()
self.assertEqual(int(result.get("internal_created") or 0), 2)
rows = db.query(Notification).filter(Notification.event_type == "REQUEST_DATA").all()
self.assertEqual(len(rows), 2)
self.assertTrue(any(str(row.recipient_admin_user_id or "") == str(admin.id) for row in rows))
self.assertTrue(any(str(row.recipient_admin_user_id or "") == str(lawyer.id) for row in rows))
class NotificationSlaTests(unittest.TestCase): class NotificationSlaTests(unittest.TestCase):
@classmethod @classmethod

View file

@ -6,7 +6,7 @@ from unittest.mock import patch
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete from sqlalchemy import create_engine, delete, text
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@ -20,7 +20,8 @@ os.environ.setdefault("S3_BUCKET", "test")
from app.core.config import settings from app.core.config import settings
from app.core.security import create_jwt from app.core.security import create_jwt
from app.db.session import get_db from app.db.session import get_db
from app.main import app from app.chat_main import app as chat_app
from app.main import app as main_app
from app.models.attachment import Attachment from app.models.attachment import Attachment
from app.models.message import Message from app.models.message import Message
from app.models.notification import Notification from app.models.notification import Notification
@ -103,12 +104,16 @@ class PublicCabinetTests(unittest.TestCase):
finally: finally:
db.close() db.close()
app.dependency_overrides[get_db] = override_get_db main_app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app) chat_app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(main_app)
self.chat_client = TestClient(chat_app)
def tearDown(self): def tearDown(self):
self.chat_client.close()
self.client.close() self.client.close()
app.dependency_overrides.clear() chat_app.dependency_overrides.clear()
main_app.dependency_overrides.clear()
clear_presence_for_tests() clear_presence_for_tests()
@staticmethod @staticmethod
@ -231,7 +236,7 @@ class PublicCabinetTests(unittest.TestCase):
db.commit() db.commit()
cookies = self._public_cookies("TRK-CHAT-001") cookies = self._public_cookies("TRK-CHAT-001")
created = self.client.post( created = self.chat_client.post(
"/api/public/chat/requests/TRK-CHAT-001/messages", "/api/public/chat/requests/TRK-CHAT-001/messages",
cookies=cookies, cookies=cookies,
json={"body": "Сообщение через выделенный сервис"}, json={"body": "Сообщение через выделенный сервис"},
@ -239,14 +244,79 @@ class PublicCabinetTests(unittest.TestCase):
self.assertEqual(created.status_code, 201) self.assertEqual(created.status_code, 201)
self.assertEqual(created.json()["author_type"], "CLIENT") self.assertEqual(created.json()["author_type"], "CLIENT")
listed = self.client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=cookies) listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=cookies)
self.assertEqual(listed.status_code, 200) self.assertEqual(listed.status_code, 200)
self.assertEqual(len(listed.json()), 1) self.assertEqual(len(listed.json()), 1)
self.assertIn("выделенный сервис", listed.json()[0]["body"]) self.assertIn("выделенный сервис", listed.json()[0]["body"])
denied = self.client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER")) denied = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER"))
self.assertEqual(denied.status_code, 403) self.assertEqual(denied.status_code, 403)
def test_chat_message_is_encrypted_at_rest(self):
with self.SessionLocal() as db:
req = Request(
track_number="TRK-CHAT-ENC",
client_name="Клиент Шифрование",
client_phone="+79997779999",
topic_code="consulting",
status_code="NEW",
description="Проверка шифрования чата",
extra_fields={},
)
db.add(req)
db.commit()
payload_body = "Секретное сообщение клиента"
cookies = self._public_cookies("TRK-CHAT-ENC")
created = self.chat_client.post(
"/api/public/chat/requests/TRK-CHAT-ENC/messages",
cookies=cookies,
json={"body": payload_body},
)
self.assertEqual(created.status_code, 201)
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:v1:"))
self.assertNotEqual(str(raw_encrypted), payload_body)
listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-ENC/messages", cookies=cookies)
self.assertEqual(listed.status_code, 200)
self.assertEqual(listed.json()[0]["body"], payload_body)
def test_chat_supports_legacy_plaintext_rows(self):
with self.SessionLocal() as db:
req = Request(
track_number="TRK-CHAT-LEGACY",
client_name="Клиент Legacy",
client_phone="+79997778888",
topic_code="consulting",
status_code="NEW",
description="Проверка legacy формата",
extra_fields={},
)
db.add(req)
db.flush()
message = Message(
request_id=req.id,
author_type="LAWYER",
author_name="Юрист",
body="legacy placeholder",
)
db.add(message)
db.flush()
db.execute(
text("UPDATE messages SET body = :body WHERE rowid = (SELECT rowid FROM messages ORDER BY created_at DESC LIMIT 1)"),
{"body": "LEGACY_PLAINTEXT_MESSAGE"},
)
db.commit()
cookies = self._public_cookies("TRK-CHAT-LEGACY")
listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-LEGACY/messages", cookies=cookies)
self.assertEqual(listed.status_code, 200)
self.assertEqual(len(listed.json()), 1)
self.assertEqual(listed.json()[0]["body"], "LEGACY_PLAINTEXT_MESSAGE")
def test_public_live_endpoint_and_typing_state(self): def test_public_live_endpoint_and_typing_state(self):
with self.SessionLocal() as db: with self.SessionLocal() as db:
req = Request( req = Request(
@ -272,7 +342,7 @@ class PublicCabinetTests(unittest.TestCase):
request_id = str(req.id) request_id = str(req.id)
cookies = self._public_cookies("TRK-LIVE-001") cookies = self._public_cookies("TRK-LIVE-001")
live_initial = self.client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies) live_initial = self.chat_client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies)
self.assertEqual(live_initial.status_code, 200) self.assertEqual(live_initial.status_code, 200)
live_body = live_initial.json() live_body = live_initial.json()
self.assertTrue(bool(live_body.get("has_updates"))) self.assertTrue(bool(live_body.get("has_updates")))
@ -285,13 +355,13 @@ class PublicCabinetTests(unittest.TestCase):
actor_role="LAWYER", actor_role="LAWYER",
typing=True, typing=True,
) )
live_with_typing = self.client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies) live_with_typing = self.chat_client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies)
self.assertEqual(live_with_typing.status_code, 200) self.assertEqual(live_with_typing.status_code, 200)
typing_rows = live_with_typing.json().get("typing") or [] typing_rows = live_with_typing.json().get("typing") or []
self.assertTrue(any(str(item.get("actor_label")) == "Юрист Тест" for item in typing_rows)) self.assertTrue(any(str(item.get("actor_label")) == "Юрист Тест" for item in typing_rows))
current_cursor = str(live_with_typing.json().get("cursor") or "") current_cursor = str(live_with_typing.json().get("cursor") or "")
live_no_delta = self.client.get( live_no_delta = self.chat_client.get(
"/api/public/chat/requests/TRK-LIVE-001/live", "/api/public/chat/requests/TRK-LIVE-001/live",
params={"cursor": current_cursor}, params={"cursor": current_cursor},
cookies=cookies, cookies=cookies,
@ -299,7 +369,7 @@ class PublicCabinetTests(unittest.TestCase):
self.assertEqual(live_no_delta.status_code, 200) self.assertEqual(live_no_delta.status_code, 200)
self.assertFalse(bool(live_no_delta.json().get("has_updates"))) self.assertFalse(bool(live_no_delta.json().get("has_updates")))
typing_on = self.client.post( typing_on = self.chat_client.post(
"/api/public/chat/requests/TRK-LIVE-001/typing", "/api/public/chat/requests/TRK-LIVE-001/typing",
cookies=cookies, cookies=cookies,
json={"typing": True}, json={"typing": True},