From 9c0457f07f5035e935fed9734d28531dcbdb9e13 Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:45:08 +0300 Subject: [PATCH] add deploy --- README.md | 71 ++++- .../versions/0027_encrypt_chat_messages.py | 52 ++++ app/api/admin/chat.py | 23 +- app/api/admin/crud_modules/service.py | 52 ++++ app/api/admin/metrics.py | 31 ++- app/api/admin/requests_modules/service.py | 98 ++++++- app/api/admin/requests_modules/status_flow.py | 20 ++ app/api/admin/router.py | 3 +- app/api/public/chat.py | 20 +- app/api/public/requests.py | 43 ++- app/api/public/router.py | 3 +- app/chat_main.py | 31 +++ app/core/config.py | 1 + app/db/encrypted_types.py | 17 ++ app/models/message.py | 5 +- app/services/chat_crypto.py | 83 ++++++ app/services/chat_secure_service.py | 259 ++++++++++++++++++ app/services/chat_service.py | 257 ++--------------- app/services/notifications.py | 109 +++++++- app/services/request_read_markers.py | 3 + app/web/admin.jsx | 16 +- .../features/requests/RequestWorkspace.jsx | 2 +- .../features/requests/RequestsSection.jsx | 28 +- app/web/admin/shared/constants.js | 3 + app/web/client.jsx | 5 +- celerybeat-schedule | Bin 16384 -> 16384 bytes context/11_test_runbook.md | 14 +- context/13_production_deploy_ruakb.md | 44 +++ deploy/caddy/Caddyfile | 16 ++ docker-compose.prod.yml | 34 +++ docker-compose.yml | 80 +++++- frontend/nginx.conf | 24 ++ scripts/ops/check_chat_health.sh | 29 ++ scripts/ops/deploy_prod.sh | 25 ++ tests/admin/test_lawyer_chat.py | 36 ++- tests/test_notifications.py | 118 ++++++++ tests/test_public_cabinet.py | 94 ++++++- 37 files changed, 1450 insertions(+), 299 deletions(-) create mode 100644 alembic/versions/0027_encrypt_chat_messages.py create mode 100644 app/chat_main.py create mode 100644 app/db/encrypted_types.py create mode 100644 app/services/chat_crypto.py create mode 100644 app/services/chat_secure_service.py create mode 100644 context/13_production_deploy_ruakb.md create mode 100644 deploy/caddy/Caddyfile create mode 100644 docker-compose.prod.yml create mode 100755 scripts/ops/check_chat_health.sh create mode 100755 scripts/ops/deploy_prod.sh diff --git a/README.md b/README.md index f6dea8b..a3fb341 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # 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) ```bash @@ -10,6 +10,33 @@ Landing (frontend): http://localhost:8081 Admin UI: http://localhost:8081/admin API (backend): http://localhost:8002 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 ```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): `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= +DATA_ENCRYPTION_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. diff --git a/alembic/versions/0027_encrypt_chat_messages.py b/alembic/versions/0027_encrypt_chat_messages.py new file mode 100644 index 0000000..3b2d0e6 --- /dev/null +++ b/alembic/versions/0027_encrypt_chat_messages.py @@ -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)}, + ) diff --git a/app/api/admin/chat.py b/app/api/admin/chat.py index 2fdc92d..3ef294c 100644 --- a/app/api/admin/chat.py +++ b/app/api/admin/chat.py @@ -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_item import RequestDataTemplateItem 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, get_chat_activity_summary, 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_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))), "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) _ensure_lawyer_can_manage_request_or_403(admin, req) actor_role = str(admin.get("role") or "").strip().upper() + responsible = str(admin.get("email") or "").strip() or "Администратор системы" body = payload or {} raw_items = body.get("items") @@ -639,6 +647,7 @@ def upsert_data_request_batch( actor_role=role, actor_name=actor_name, actor_admin_user_id=actor_admin_user_id, + event_type=NOTIFICATION_EVENT_REQUEST_DATA, ) message_uuid = existing_message.id @@ -770,6 +779,18 @@ def upsert_data_request_batch( for row in existing_message_rows: if row.key not in touched_keys: 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() fresh_messages = list_messages_for_request(db, req.id) diff --git a/app/api/admin/crud_modules/service.py b/app/api/admin/crud_modules/service.py index 0131e5a..6448d0e 100644 --- a/app/api/admin/crud_modules/service.py +++ b/app/api/admin/crud_modules/service.py @@ -18,15 +18,19 @@ from app.models.table_availability import TableAvailability from app.schemas.universal import UniversalQuery from app.services.billing_flow import apply_billing_transition_effects from app.services.notifications import ( + EVENT_ASSIGNMENT as NOTIFICATION_EVENT_ASSIGNMENT, EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, + EVENT_REASSIGNMENT as NOTIFICATION_EVENT_REASSIGNMENT, EVENT_STATUS as NOTIFICATION_EVENT_STATUS, mark_admin_notifications_read, notify_request_event, ) from app.services.request_read_markers import ( + EVENT_ASSIGNMENT, EVENT_ATTACHMENT, EVENT_MESSAGE, + EVENT_REASSIGNMENT, EVENT_STATUS, clear_unread_for_lawyer, 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}", 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]: @@ -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): clean_payload["responsible"] = responsible 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": 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() @@ -510,8 +535,35 @@ def update_row_service(table_name: str, row_id: str, payload: dict[str, Any], db ), 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(): 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: db.add(row) diff --git a/app/api/admin/metrics.py b/app/api/admin/metrics.py index d2b94fc..9ce4e64 100644 --- a/app/api/admin/metrics.py +++ b/app/api/admin/metrics.py @@ -16,6 +16,11 @@ from app.models.request import Request from app.models.request_service_request import RequestServiceRequest from app.models.status import Status 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 router = APIRouter() @@ -99,18 +104,25 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", now_utc = datetime.now(timezone.utc) month_start, next_month_start = _month_bounds(now_utc) - unread_for_clients = ( + unread_for_clients_flags = ( db.query(func.count(Request.id)) .filter(Request.client_has_unread_updates.is_(True)) .scalar() or 0 ) - unread_for_lawyers = ( + unread_for_lawyers_flags = ( db.query(func.count(Request.id)) .filter(Request.lawyer_has_unread_updates.is_(True)) .scalar() 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: service_request_unread_total = int( db.query(func.count(RequestServiceRequest.id)) @@ -267,6 +279,11 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", .all() ) 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)] elif role == "LAWYER": by_status = {} @@ -293,8 +310,8 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", 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_by_event = {} + my_unread_updates = int(my_unread_notifications.get("total") or 0) + my_unread_by_event = dict(my_unread_notifications.get("by_event") or {}) scoped_lawyer_loads = lawyer_loads 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, "my_unread_updates": my_unread_updates, "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, "month_revenue": monthly_revenue, "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", {}), "unread_for_clients": int(unread_for_clients), "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), "lawyer_loads": scoped_lawyer_loads, } diff --git a/app/api/admin/requests_modules/service.py b/app/api/admin/requests_modules/service.py index ca6764c..0f94555 100644 --- a/app/api/admin/requests_modules/service.py +++ b/app/api/admin/requests_modules/service.py @@ -6,22 +6,32 @@ from uuid import UUID, uuid4 from fastapi import HTTPException from sqlalchemy import case, func, or_, update -from sqlalchemy.exc import IntegrityError +from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.orm import Session from app.models.admin_user import AdminUser from app.models.audit_log import AuditLog +from app.models.notification import Notification from app.models.request import Request from app.models.request_service_request import RequestServiceRequest from app.schemas.admin import RequestAdminCreate, RequestAdminPatch from app.schemas.universal import UniversalQuery from app.services.billing_flow import apply_billing_transition_effects from app.services.notifications import ( + EVENT_ASSIGNMENT as NOTIFICATION_EVENT_ASSIGNMENT, + EVENT_REASSIGNMENT as NOTIFICATION_EVENT_REASSIGNMENT, EVENT_STATUS as NOTIFICATION_EVENT_STATUS, mark_admin_notifications_read, 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_templates import validate_required_topic_fields_or_400 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] unread_service_requests_by_request: dict[str, int] = {} + viewer_unread_by_request: dict[str, dict[str, Any]] = {} if row_ids: unread_query = ( 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_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 { "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, "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)), + "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, "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: changes["effective_rate"] = assigned_lawyer.default_rate 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 "Администратор системы" if {"client_id", "client_name", "client_phone"}.intersection(set(changes.keys())): 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(): 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: next_status = str(changes.get("status_code") or "") important_date_at = row.important_date_at @@ -260,6 +307,24 @@ def update_request_service(request_id: str, payload: RequestAdminPatch, db: Sess ), 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: db.add(row) db.commit() @@ -388,6 +453,20 @@ def claim_request_service(request_id: str, db: Session, admin: dict) -> dict[str if row is None: 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 { "status": "claimed", "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: 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 { "status": "reassigned", "id": str(row.id), diff --git a/app/api/admin/requests_modules/status_flow.py b/app/api/admin/requests_modules/status_flow.py index 16490be..6184a43 100644 --- a/app/api/admin/requests_modules/status_flow.py +++ b/app/api/admin/requests_modules/status_flow.py @@ -2,11 +2,13 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Any +from uuid import UUID from fastapi import HTTPException from sqlalchemy import or_ from sqlalchemy.orm import Session +from app.models.notification import Notification from app.models.request import Request from app.models.status import Status 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}"') expected = coerce_request_bool_filter_or_400(clause.value) 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": expr = Request.lawyer_has_unread_updates.is_(True) + if actor_expr is not None: + expr = or_(expr, actor_expr) else: expr = or_( Request.lawyer_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": 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) diff --git a/app/api/admin/router.py b/app/api/admin/router.py index 2f0c703..069c7d4 100644 --- a/app/api/admin/router.py +++ b/app/api/admin/router.py @@ -1,5 +1,5 @@ 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.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(notifications.router, prefix="/notifications", tags=["AdminNotifications"]) 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(test_utils.router, prefix="/test-utils", tags=["AdminTestUtils"]) router.include_router(system.router, prefix="/system", tags=["AdminSystem"]) diff --git a/app/api/public/chat.py b/app/api/public/chat.py index 809a7a8..c1a559f 100644 --- a/app/api/public/chat.py +++ b/app/api/public/chat.py @@ -13,14 +13,15 @@ from app.models.request import Request from app.models.request_data_requirement import RequestDataRequirement from app.schemas.public import PublicMessageCreate 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, get_chat_activity_summary, list_messages_for_request, serialize_message, serialize_messages_for_request, ) -from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_lawyer +from app.services.request_read_markers import EVENT_REQUEST_DATA, mark_unread_for_lawyer 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_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))), "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 if updated: - mark_unread_for_lawyer(req, EVENT_MESSAGE) + mark_unread_for_lawyer(req, EVENT_REQUEST_DATA) 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.commit() else: diff --git a/app/api/public/requests.py b/app/api/public/requests.py index 8c12bd8..70163b8 100644 --- a/app/api/public/requests.py +++ b/app/api/public/requests.py @@ -6,6 +6,7 @@ from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Response from fastapi.responses import StreamingResponse +from sqlalchemy import func from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError @@ -19,18 +20,20 @@ from app.models.client import Client from app.models.invoice import Invoice from app.models.message import Message from app.models.audit_log import AuditLog +from app.models.notification import Notification from app.models.request import Request from app.models.request_service_request import RequestServiceRequest from app.models.status_history import StatusHistory from app.models.topic import Topic from app.services.invoice_crypto import decrypt_requisites 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 ( get_client_notification, list_client_notifications, mark_client_notifications_read, serialize_notification, + unread_client_summary, ) from app.services.request_read_markers import clear_unread_for_client 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) 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 { "rows": [ { @@ -250,6 +281,8 @@ def list_my_requests( "status_code": row.status_code, "client_has_unread_updates": bool(row.client_has_unread_updates), "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), "updated_at": _to_iso(row.updated_at), } @@ -302,6 +335,12 @@ def get_request_by_track( db.commit() db.refresh(req) + unread_after_open = unread_client_summary( + db, + track_number=req.track_number, + request_id=req.id, + ) + return { "id": str(req.id), "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, "lawyer_has_unread_updates": req.lawyer_has_unread_updates, "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), "updated_at": _to_iso(req.updated_at), } diff --git a/app/api/public/router.py b/app/api/public/router.py index f038e03..165eeff 100644 --- a/app/api/public/router.py +++ b/app/api/public/router.py @@ -1,5 +1,5 @@ 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.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(featured_staff.router, prefix="/featured-staff", tags=["Public"]) router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"]) -router.include_router(chat.router, prefix="/chat", tags=["PublicChat"]) diff --git a/app/chat_main.py b/app/chat_main.py new file mode 100644 index 0000000..a2021f5 --- /dev/null +++ b/app/chat_main.py @@ -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"} diff --git a/app/core/config.py b/app/core/config.py index 6649297..92cafb0 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -32,6 +32,7 @@ class Settings(BaseSettings): SMSAERO_API_KEY: str = "" OTP_SMS_TEMPLATE: str = "Your verification code: {code}" DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption" + CHAT_ENCRYPTION_SECRET: str = "" OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300 OTP_SEND_RATE_LIMIT: int = 8 OTP_VERIFY_RATE_LIMIT: int = 20 diff --git a/app/db/encrypted_types.py b/app/db/encrypted_types.py new file mode 100644 index 0000000..b4efe49 --- /dev/null +++ b/app/db/encrypted_types.py @@ -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) diff --git a/app/models/message.py b/app/models/message.py index 1b29d19..18ff153 100644 --- a/app/models/message.py +++ b/app/models/message.py @@ -1,8 +1,9 @@ import uuid -from sqlalchemy import String, Text, Boolean +from sqlalchemy import String, Boolean from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID from app.db.session import Base +from app.db.encrypted_types import EncryptedChatText from app.models.common import UUIDMixin, TimestampMixin 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) author_type: Mapped[str] = mapped_column(String(20), nullable=False) # CLIENT|LAWYER|SYSTEM author_name: Mapped[str | None] = mapped_column(String(200), nullable=True) - body: Mapped[str | None] = mapped_column(Text, nullable=True) + body: Mapped[str | None] = mapped_column(EncryptedChatText(), nullable=True) immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) diff --git a/app/services/chat_crypto.py b/app/services/chat_crypto.py new file mode 100644 index 0000000..dd307d9 --- /dev/null +++ b/app/services/chat_crypto.py @@ -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") diff --git a/app/services/chat_secure_service.py b/app/services/chat_secure_service.py new file mode 100644 index 0000000..8a59242 --- /dev/null +++ b/app/services/chat_secure_service.py @@ -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, + } diff --git a/app/services/chat_service.py b/app/services/chat_service.py index 3a6f224..3d662d6 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -1,243 +1,20 @@ 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 ( + create_admin_or_lawyer_message, + create_client_message, + get_chat_activity_summary, + list_messages_for_request, + serialize_message, + serialize_messages_for_request, +) -from fastapi import HTTPException -from sqlalchemy import func -from sqlalchemy.orm import Session - -from app.models.message import Message -from app.models.attachment import Attachment -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() - ) - - -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) -> 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, - } +__all__ = [ + "create_admin_or_lawyer_message", + "create_client_message", + "get_chat_activity_summary", + "list_messages_for_request", + "serialize_message", + "serialize_messages_for_request", +] diff --git a/app/services/notifications.py b/app/services/notifications.py index 0d817cf..4600c2a 100644 --- a/app/services/notifications.py +++ b/app/services/notifications.py @@ -4,7 +4,7 @@ import uuid from datetime import datetime, timezone from typing import Any -from sqlalchemy import and_ +from sqlalchemy import and_, func from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session @@ -20,12 +20,18 @@ EVENT_MESSAGE = "MESSAGE" EVENT_ATTACHMENT = "ATTACHMENT" EVENT_STATUS = "STATUS" EVENT_SLA_OVERDUE = "SLA_OVERDUE" +EVENT_REQUEST_DATA = "REQUEST_DATA" +EVENT_ASSIGNMENT = "ASSIGNMENT" +EVENT_REASSIGNMENT = "REASSIGNMENT" _EVENT_LABELS = { EVENT_MESSAGE: "Новое сообщение", EVENT_ATTACHMENT: "Новый файл", EVENT_STATUS: "Изменен статус", EVENT_SLA_OVERDUE: "SLA просрочен", + EVENT_REQUEST_DATA: "Запрос/обновление данных", + EVENT_ASSIGNMENT: "Заявка назначена", + EVENT_REASSIGNMENT: "Заявка переназначена", } @@ -231,7 +237,7 @@ def notify_request_event( if row is not None: internal_created += 1 - if event in {EVENT_MESSAGE, EVENT_ATTACHMENT}: + if event in {EVENT_MESSAGE, EVENT_ATTACHMENT, EVENT_REQUEST_DATA}: if actor == "CLIENT": _notify_lawyer_if_any() _notify_admins() @@ -241,6 +247,10 @@ def notify_request_event( _notify_client() if actor == "ADMIN": _notify_lawyer_if_any() + elif event in {EVENT_ASSIGNMENT, EVENT_REASSIGNMENT}: + _notify_client() + _notify_lawyer_if_any() + _notify_admins() elif event == EVENT_SLA_OVERDUE: _notify_lawyer_if_any() _notify_admins() @@ -434,3 +444,98 @@ def get_client_notification( ) .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} diff --git a/app/services/request_read_markers.py b/app/services/request_read_markers.py index c301973..bbd7468 100644 --- a/app/services/request_read_markers.py +++ b/app/services/request_read_markers.py @@ -5,6 +5,9 @@ from app.models.request import Request EVENT_MESSAGE = "MESSAGE" EVENT_ATTACHMENT = "ATTACHMENT" 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: diff --git a/app/web/admin.jsx b/app/web/admin.jsx index a2ecf33..47cfb46 100644 --- a/app/web/admin.jsx +++ b/app/web/admin.jsx @@ -896,6 +896,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; lawyerLoads: [], myUnreadByEvent: {}, myUnreadTotal: 0, + myUnreadNotificationsTotal: 0, unreadForClients: 0, unreadForLawyers: 0, serviceRequestUnreadTotal: 0, @@ -1762,7 +1763,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; { label: "Мои заявки", value: data.assigned_total ?? 0 }, { label: "Мои активные", value: data.active_assigned_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 }, ] : [ @@ -1770,6 +1771,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; { label: "Назначенные", value: data.assigned_total ?? 0 }, { label: "Неназначенные", value: data.unassigned_total ?? 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_expenses ?? 0).toFixed(2) }, { label: "Непрочитано юристами", value: data.unread_for_lawyers ?? 0 }, @@ -1786,8 +1788,9 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; lawyerLoads: data.lawyer_loads || [], myUnreadByEvent: data.my_unread_by_event || {}, myUnreadTotal: Number(data.my_unread_updates || 0), - unreadForClients: Number(data.unread_for_clients || 0), - unreadForLawyers: Number(data.unread_for_lawyers || 0), + myUnreadNotificationsTotal: Number(data.my_unread_notifications_total || data.my_unread_updates || 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), deadlineAlertTotal: Number(data.deadline_alert_total || 0), monthRevenue: Number(data.month_revenue || 0), @@ -2629,6 +2632,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; lawyerLoads: [], myUnreadByEvent: {}, myUnreadTotal: 0, + myUnreadNotificationsTotal: 0, unreadForClients: 0, unreadForLawyers: 0, serviceRequestUnreadTotal: 0, @@ -2789,9 +2793,11 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; const topbarUnreadCount = useMemo(() => { 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); - }, [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 topbarServiceRequestUnreadCount = useMemo( diff --git a/app/web/admin/features/requests/RequestWorkspace.jsx b/app/web/admin/features/requests/RequestWorkspace.jsx index fa17df6..1f82e95 100644 --- a/app/web/admin/features/requests/RequestWorkspace.jsx +++ b/app/web/admin/features/requests/RequestWorkspace.jsx @@ -1767,7 +1767,7 @@ export function RequestWorkspace({ )} {clientDataModal.error ?
{clientDataModal.error}
: null} -
+
{clientDataModal.status || ""}
diff --git a/app/web/admin/features/requests/RequestsSection.jsx b/app/web/admin/features/requests/RequestsSection.jsx index 0cbc08f..2ad86c5 100644 --- a/app/web/admin/features/requests/RequestsSection.jsx +++ b/app/web/admin/features/requests/RequestsSection.jsx @@ -4,12 +4,30 @@ import { fmtDate, statusLabel } from "../../shared/utils.js"; function renderRequestUpdatesCell(row, role) { const hasServiceRequestUnread = Boolean(row?.has_service_requests_unread); 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") { const has = Boolean(row.lawyer_has_unread_updates); const eventType = String(row.lawyer_unread_event_type || "").toUpperCase(); - if (!has && !hasServiceRequestUnread) return нет; + if (!has && !hasServiceRequestUnread && !viewerUnreadTotal) return нет; return ( + {viewerUnreadTotal > 0 ? ( + + + {"Мне: " + String(viewerUnreadTotal)} + + ) : null} {has ? ( @@ -31,9 +49,15 @@ function renderRequestUpdatesCell(row, role) { const lawyerHas = Boolean(row.lawyer_has_unread_updates); const lawyerType = String(row.lawyer_unread_event_type || "").toUpperCase(); - if (!clientHas && !lawyerHas && !hasServiceRequestUnread) return нет; + if (!clientHas && !lawyerHas && !hasServiceRequestUnread && !viewerUnreadTotal) return нет; return ( + {viewerUnreadTotal > 0 ? ( + + + {"Мне: " + String(viewerUnreadTotal)} + + ) : null} {clientHas ? ( diff --git a/app/web/admin/shared/constants.js b/app/web/admin/shared/constants.js index 4c9f23c..a089673 100644 --- a/app/web/admin/shared/constants.js +++ b/app/web/admin/shared/constants.js @@ -44,6 +44,9 @@ export const STATUS_KIND_LABELS = { export const REQUEST_UPDATE_EVENT_LABELS = { MESSAGE: "сообщение", ATTACHMENT: "файл", + REQUEST_DATA: "данные", + ASSIGNMENT: "назначение", + REASSIGNMENT: "переназначение", STATUS: "статус", }; diff --git a/app/web/client.jsx b/app/web/client.jsx index 5332336..294a0f5 100644 --- a/app/web/client.jsx +++ b/app/web/client.jsx @@ -809,7 +809,10 @@ import { detectAttachmentPreviewKind, fmtShortDateTime } from "./admin/shared/ut > {requestsList.map((row) => ( ))} diff --git a/celerybeat-schedule b/celerybeat-schedule index 6655dacb157bf429dfb3b0dbf8b2d0a14f04ffae..a6c5d52270fe2987be18f381857e5378e9b37c5f 100644 GIT binary patch delta 85 zcmZo@U~Fh$+%U^lK!!`+n$_aTlnk-9DM3@b-6yZJmYclC+-{OJo1hH0C>J~TF{qp` XGt*>QyAFsB$0lSQ3Y)LmX)^)d?mYclC+-{OJo1nCasv5(g2&kOz XO~%Qxb{!BM>jjZ@C~UrJr_Bfe#z-8z diff --git a/context/11_test_runbook.md b/context/11_test_runbook.md index ec9408c..1e3cd2e 100644 --- a/context/11_test_runbook.md +++ b/context/11_test_runbook.md @@ -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 ``` Доступы и список тестовых заявок сохраняются в `/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 | Что проверяем | Где тесты | Как запускать | @@ -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` | | 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 | -| 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 | | 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` | diff --git a/context/13_production_deploy_ruakb.md b/context/13_production_deploy_ruakb.md new file mode 100644 index 0000000..c4de110 --- /dev/null +++ b/context/13_production_deploy_ruakb.md @@ -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 +``` diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 0000000..c4700ca --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -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 +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..a3decaf --- /dev/null +++ b/docker-compose.prod.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 482d313..ecd69de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,18 @@ services: context: . dockerfile: frontend/Dockerfile 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"] e2e: @@ -14,7 +25,9 @@ services: image: law-e2e-playwright:1.58.2 container_name: law-e2e working_dir: /src/e2e - depends_on: [frontend] + depends_on: + frontend: + condition: service_healthy volumes: - .:/src - /src/e2e/node_modules @@ -25,45 +38,102 @@ services: backend: build: . container_name: law-backend + restart: unless-stopped 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"] 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: build: . container_name: law-worker + restart: unless-stopped 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"] volumes: [".:/app"] beat: build: . container_name: law-beat + restart: unless-stopped env_file: .env - depends_on: [redis] + depends_on: + redis: + condition: service_healthy command: ["celery","-A","app.workers.celery_app:celery_app","beat","-l","INFO"] volumes: [".:/app"] db: image: postgres:16 container_name: law-db + restart: unless-stopped environment: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres POSTGRES_DB: legal ports: ["5432:5432"] 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: image: redis:7 container_name: law-redis + restart: unless-stopped ports: ["6379:6379"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s minio: image: minio/minio:latest container_name: law-minio + restart: unless-stopped command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: minioadmin diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 880ee87..62f78e2 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -60,6 +60,24 @@ server { 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/ { proxy_pass http://backend:8000; proxy_http_version 1.1; @@ -83,4 +101,10 @@ server { proxy_http_version 1.1; 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; + } } diff --git a/scripts/ops/check_chat_health.sh b/scripts/ops/check_chat_health.sh new file mode 100755 index 0000000..1d3be41 --- /dev/null +++ b/scripts/ops/check_chat_health.sh @@ -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" diff --git a/scripts/ops/deploy_prod.sh b/scripts/ops/deploy_prod.sh new file mode 100755 index 0000000..25c317c --- /dev/null +++ b/scripts/ops/deploy_prod.sh @@ -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" diff --git a/tests/admin/test_lawyer_chat.py b/tests/admin/test_lawyer_chat.py index d42a60f..db1b43d 100644 --- a/tests/admin/test_lawyer_chat.py +++ b/tests/admin/test_lawyer_chat.py @@ -1,4 +1,6 @@ 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 @@ -6,8 +8,18 @@ class AdminLawyerChatTests(AdminUniversalCrudBase): def setUp(self): super().setUp() 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): + self.chat_client.close() + chat_app.dependency_overrides.clear() clear_presence_for_tests() super().tearDown() @@ -396,14 +408,14 @@ class AdminLawyerChatTests(AdminUniversalCrudBase): 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") - 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.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) - own_create = self.client.post( + own_create = self.chat_client.post( f"/api/admin/chat/requests/{own_id}/messages", headers=lawyer_headers, json={"body": "Ответ из chat service"}, @@ -411,14 +423,14 @@ class AdminLawyerChatTests(AdminUniversalCrudBase): self.assertEqual(own_create.status_code, 201) 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", headers=lawyer_headers, json={"body": "Нельзя в неназначенную"}, ) 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", headers=admin_headers, json={"body": "Сообщение администратора"}, @@ -485,10 +497,10 @@ class AdminLawyerChatTests(AdminUniversalCrudBase): lawyer_headers = self._auth_headers("LAWYER", email="lawyer.live.self@example.com", sub=self_id) admin_headers = self._auth_headers("ADMIN", email="root@example.com") - own_live = self.client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers) + own_live = self.chat_client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers) self.assertEqual(own_live.status_code, 200) own_cursor = str(own_live.json().get("cursor") or "") - own_live_no_delta = self.client.get( + own_live_no_delta = self.chat_client.get( f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers, params={"cursor": own_cursor}, @@ -496,10 +508,10 @@ class AdminLawyerChatTests(AdminUniversalCrudBase): self.assertEqual(own_live_no_delta.status_code, 200) self.assertFalse(bool(own_live_no_delta.json().get("has_updates"))) - foreign_live = self.client.get(f"/api/admin/chat/requests/{foreign_id}/live", headers=lawyer_headers) + foreign_live = self.chat_client.get(f"/api/admin/chat/requests/{foreign_id}/live", headers=lawyer_headers) self.assertEqual(foreign_live.status_code, 403) - own_typing = self.client.post( + own_typing = self.chat_client.post( f"/api/admin/chat/requests/{own_id}/typing", headers=lawyer_headers, json={"typing": True}, @@ -507,14 +519,14 @@ class AdminLawyerChatTests(AdminUniversalCrudBase): self.assertEqual(own_typing.status_code, 200) 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", headers=lawyer_headers, json={"typing": True}, ) 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", headers=admin_headers, json={"typing": True}, @@ -522,7 +534,7 @@ class AdminLawyerChatTests(AdminUniversalCrudBase): self.assertEqual(admin_typing.status_code, 200) self.assertTrue(bool(admin_typing.json().get("typing"))) - own_live_with_typing = self.client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers) + 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) typing_rows = own_live_with_typing.json().get("typing") or [] self.assertTrue(any(str(item.get("actor_role")) == "ADMIN" for item in typing_rows)) diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 0525856..9d639ab 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -29,6 +29,7 @@ from app.models.request import Request from app.models.status import Status from app.models.status_history import StatusHistory 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 @@ -274,6 +275,123 @@ class NotificationFlowTests(unittest.TestCase): self.assertEqual(len(rows), 1) 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): @classmethod diff --git a/tests/test_public_cabinet.py b/tests/test_public_cabinet.py index d445d74..325b18e 100644 --- a/tests/test_public_cabinet.py +++ b/tests/test_public_cabinet.py @@ -6,7 +6,7 @@ from unittest.mock import patch from botocore.exceptions import ClientError 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.pool import StaticPool @@ -20,7 +20,8 @@ os.environ.setdefault("S3_BUCKET", "test") from app.core.config import settings from app.core.security import create_jwt 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.message import Message from app.models.notification import Notification @@ -103,12 +104,16 @@ class PublicCabinetTests(unittest.TestCase): finally: db.close() - app.dependency_overrides[get_db] = override_get_db - self.client = TestClient(app) + main_app.dependency_overrides[get_db] = override_get_db + chat_app.dependency_overrides[get_db] = override_get_db + self.client = TestClient(main_app) + self.chat_client = TestClient(chat_app) def tearDown(self): + self.chat_client.close() self.client.close() - app.dependency_overrides.clear() + chat_app.dependency_overrides.clear() + main_app.dependency_overrides.clear() clear_presence_for_tests() @staticmethod @@ -231,7 +236,7 @@ class PublicCabinetTests(unittest.TestCase): db.commit() cookies = self._public_cookies("TRK-CHAT-001") - created = self.client.post( + created = self.chat_client.post( "/api/public/chat/requests/TRK-CHAT-001/messages", cookies=cookies, json={"body": "Сообщение через выделенный сервис"}, @@ -239,14 +244,79 @@ class PublicCabinetTests(unittest.TestCase): self.assertEqual(created.status_code, 201) 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(len(listed.json()), 1) 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) + 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): with self.SessionLocal() as db: req = Request( @@ -272,7 +342,7 @@ class PublicCabinetTests(unittest.TestCase): request_id = str(req.id) cookies = self._public_cookies("TRK-LIVE-001") - live_initial = self.client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies) + live_initial = self.chat_client.get("/api/public/chat/requests/TRK-LIVE-001/live", cookies=cookies) self.assertEqual(live_initial.status_code, 200) live_body = live_initial.json() self.assertTrue(bool(live_body.get("has_updates"))) @@ -285,13 +355,13 @@ class PublicCabinetTests(unittest.TestCase): actor_role="LAWYER", 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) typing_rows = live_with_typing.json().get("typing") or [] self.assertTrue(any(str(item.get("actor_label")) == "Юрист Тест" for item in typing_rows)) current_cursor = str(live_with_typing.json().get("cursor") or "") - live_no_delta = self.client.get( + live_no_delta = self.chat_client.get( "/api/public/chat/requests/TRK-LIVE-001/live", params={"cursor": current_cursor}, cookies=cookies, @@ -299,7 +369,7 @@ class PublicCabinetTests(unittest.TestCase): self.assertEqual(live_no_delta.status_code, 200) 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", cookies=cookies, json={"typing": True},