Test-3 commit

This commit is contained in:
TronoSfera 2026-02-25 18:18:05 +03:00
parent 7b6fd8c7c2
commit 90450b8918
50 changed files with 5202 additions and 714 deletions

View file

@ -0,0 +1,106 @@
"""add clients table and links from requests/invoices
Revision ID: 0015_clients_table_links
Revises: 0014_security_audit_log
Create Date: 2026-02-24
"""
from __future__ import annotations
import uuid
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0015_clients_table_links"
down_revision = "0014_security_audit_log"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"clients",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
sa.Column("full_name", sa.String(length=200), nullable=False),
sa.Column("phone", sa.String(length=30), nullable=False, unique=True),
)
op.create_index("ix_clients_phone", "clients", ["phone"], unique=True)
op.add_column("requests", sa.Column("client_id", postgresql.UUID(as_uuid=True), nullable=True))
op.add_column("invoices", sa.Column("client_id", postgresql.UUID(as_uuid=True), nullable=True))
op.create_index("ix_requests_client_id", "requests", ["client_id"])
op.create_index("ix_invoices_client_id", "invoices", ["client_id"])
op.create_foreign_key("fk_requests_client_id_clients", "requests", "clients", ["client_id"], ["id"])
op.create_foreign_key("fk_invoices_client_id_clients", "invoices", "clients", ["client_id"], ["id"])
bind = op.get_bind()
rows = bind.execute(
sa.text(
"""
SELECT DISTINCT ON (client_phone)
client_phone,
client_name
FROM requests
WHERE client_phone IS NOT NULL AND client_phone <> ''
ORDER BY client_phone, created_at ASC NULLS FIRST
"""
)
).mappings()
for row in rows:
client_id = uuid.uuid4()
bind.execute(
sa.text(
"""
INSERT INTO clients (id, created_at, updated_at, responsible, full_name, phone)
VALUES (:id, now(), now(), :responsible, :full_name, :phone)
ON CONFLICT (phone) DO NOTHING
"""
),
{
"id": client_id,
"responsible": "Миграция системы",
"full_name": str(row.get("client_name") or "").strip() or "Клиент",
"phone": str(row.get("client_phone") or "").strip(),
},
)
bind.execute(
sa.text(
"""
UPDATE requests AS r
SET client_id = c.id
FROM clients AS c
WHERE r.client_phone = c.phone
AND (r.client_id IS NULL OR r.client_id <> c.id)
"""
)
)
bind.execute(
sa.text(
"""
UPDATE invoices AS i
SET client_id = r.client_id
FROM requests AS r
WHERE i.request_id = r.id
AND (i.client_id IS NULL OR i.client_id <> r.client_id)
"""
)
)
def downgrade():
op.drop_constraint("fk_invoices_client_id_clients", "invoices", type_="foreignkey")
op.drop_constraint("fk_requests_client_id_clients", "requests", type_="foreignkey")
op.drop_index("ix_invoices_client_id", table_name="invoices")
op.drop_index("ix_requests_client_id", table_name="requests")
op.drop_column("invoices", "client_id")
op.drop_column("requests", "client_id")
op.drop_index("ix_clients_phone", table_name="clients")
op.drop_table("clients")

View file

@ -0,0 +1,38 @@
"""add table availability controls for dictionaries
Revision ID: 0016_table_availability
Revises: 0015_clients_table_links
Create Date: 2026-02-24
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0016_table_availability"
down_revision = "0015_clients_table_links"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"table_availability",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
sa.Column("table_name", sa.String(length=120), nullable=False, unique=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.create_index("ix_table_availability_table_name", "table_availability", ["table_name"], unique=True)
op.create_index("ix_table_availability_is_active", "table_availability", ["is_active"], unique=False)
def downgrade():
op.drop_index("ix_table_availability_is_active", table_name="table_availability")
op.drop_index("ix_table_availability_table_name", table_name="table_availability")
op.drop_table("table_availability")

View file

@ -5,13 +5,16 @@ from app.schemas.admin import AdminLogin, AdminToken
from app.core.security import create_jwt, verify_password from app.core.security import create_jwt, verify_password
from app.core.config import settings from app.core.config import settings
from app.db.session import get_db from app.db.session import get_db
from app.models.admin_user import AdminUser from app.services.admin_bootstrap import ensure_bootstrap_admin_for_login, get_active_admin_by_email, normalize_admin_email
router = APIRouter() router = APIRouter()
@router.post("/login", response_model=AdminToken) @router.post("/login", response_model=AdminToken)
def login(payload: AdminLogin, db: Session = Depends(get_db)): def login(payload: AdminLogin, db: Session = Depends(get_db)):
user = db.query(AdminUser).filter(AdminUser.email == payload.email, AdminUser.is_active == True).first() email = normalize_admin_email(payload.email)
user = ensure_bootstrap_admin_for_login(db, email, payload.password)
if user is None:
user = get_active_admin_by_email(db, email)
if not user or not verify_password(payload.password, user.password_hash): if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(status_code=401, detail="Неверный логин или пароль") raise HTTPException(status_code=401, detail="Неверный логин или пароль")
token = create_jwt({"sub": str(user.id), "email": user.email, "role": user.role}, token = create_jwt({"sub": str(user.id), "email": user.email, "role": user.role},

86
app/api/admin/chat.py Normal file
View file

@ -0,0 +1,86 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.deps import require_role
from app.db.session import get_db
from app.models.request import Request
from app.services.chat_service import create_admin_or_lawyer_message, list_messages_for_request, serialize_message
router = APIRouter()
def _request_uuid_or_400(request_id: str) -> UUID:
try:
return UUID(str(request_id))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректный идентификатор заявки")
def _request_for_id_or_404(db: Session, request_id: str) -> Request:
req = db.get(Request, _request_uuid_or_400(request_id))
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
return req
def _ensure_lawyer_can_view_request_or_403(admin: dict, req: Request) -> None:
role = str(admin.get("role") or "").upper()
if role != "LAWYER":
return
actor = str(admin.get("sub") or "").strip()
if not actor:
raise HTTPException(status_code=401, detail="Некорректный токен")
assigned = str(req.assigned_lawyer_id or "").strip()
if assigned and actor != assigned:
raise HTTPException(status_code=403, detail="Юрист может видеть только свои и неназначенные заявки")
def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
role = str(admin.get("role") or "").upper()
if role != "LAWYER":
return
actor = str(admin.get("sub") or "").strip()
if not actor:
raise HTTPException(status_code=401, detail="Некорректный токен")
assigned = str(req.assigned_lawyer_id or "").strip()
if not assigned or actor != assigned:
raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками")
@router.get("/requests/{request_id}/messages")
def list_request_messages(
request_id: str,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
req = _request_for_id_or_404(db, request_id)
_ensure_lawyer_can_view_request_or_403(admin, req)
rows = list_messages_for_request(db, req.id)
return {"rows": [serialize_message(row) for row in rows], "total": len(rows)}
@router.post("/requests/{request_id}/messages", status_code=201)
def create_request_message(
request_id: str,
payload: dict,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
req = _request_for_id_or_404(db, request_id)
_ensure_lawyer_can_manage_request_or_403(admin, req)
body = str((payload or {}).get("body") or "").strip()
role = str(admin.get("role") or "").upper()
actor_name = str(admin.get("email") or "").strip() or ("Юрист" if role == "LAWYER" else "Администратор")
row = create_admin_or_lawyer_message(
db,
request=req,
body=body,
actor_role=role,
actor_name=actor_name,
actor_admin_user_id=str(admin.get("sub") or "").strip() or None,
)
return serialize_message(row)

View file

@ -3,12 +3,13 @@ from __future__ import annotations
import importlib import importlib
import pkgutil import pkgutil
import uuid import uuid
from datetime import date, datetime from datetime import date, datetime, timezone
from decimal import Decimal from decimal import Decimal
from functools import lru_cache from functools import lru_cache
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.inspection import inspect as sa_inspect from sqlalchemy.inspection import inspect as sa_inspect
@ -22,6 +23,8 @@ from app.db.session import Base, get_db
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.form_field import FormField from app.models.form_field import FormField
from app.models.client import Client
from app.models.table_availability import TableAvailability
from app.models.request_data_requirement import RequestDataRequirement from app.models.request_data_requirement import RequestDataRequirement
from app.models.attachment import Attachment from app.models.attachment import Attachment
from app.models.message import Message from app.models.message import Message
@ -66,6 +69,8 @@ SYSTEM_FIELDS = {
"lawyer_unread_event_type", "lawyer_unread_event_type",
} }
REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"} REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"}
REQUEST_CALCULATED_FIELDS = {"invoice_amount", "paid_at", "paid_by_admin_id", "total_attachments_bytes"}
INVOICE_CALCULATED_FIELDS = {"issued_by_admin_user_id", "issued_by_role", "issued_at", "paid_at"}
ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"} ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"}
# Per-table RBAC: table -> role -> actions. # Per-table RBAC: table -> role -> actions.
@ -75,10 +80,20 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
"ADMIN": set(CRUD_ACTIONS), "ADMIN": set(CRUD_ACTIONS),
"LAWYER": set(CRUD_ACTIONS), "LAWYER": set(CRUD_ACTIONS),
}, },
"messages": {
"ADMIN": set(CRUD_ACTIONS),
"LAWYER": {"query", "read", "create"},
},
"attachments": {
"ADMIN": set(CRUD_ACTIONS),
"LAWYER": {"query", "read"},
},
"quotes": {"ADMIN": set(CRUD_ACTIONS)}, "quotes": {"ADMIN": set(CRUD_ACTIONS)},
"topics": {"ADMIN": set(CRUD_ACTIONS)}, "topics": {"ADMIN": set(CRUD_ACTIONS)},
"statuses": {"ADMIN": set(CRUD_ACTIONS)}, "statuses": {"ADMIN": set(CRUD_ACTIONS)},
"form_fields": {"ADMIN": set(CRUD_ACTIONS)}, "form_fields": {"ADMIN": set(CRUD_ACTIONS)},
"clients": {"ADMIN": set(CRUD_ACTIONS)},
"table_availability": {"ADMIN": set(CRUD_ACTIONS)},
"audit_log": {"ADMIN": {"query", "read"}}, "audit_log": {"ADMIN": {"query", "read"}},
"security_audit_log": {"ADMIN": {"query", "read"}}, "security_audit_log": {"ADMIN": {"query", "read"}},
"otp_sessions": {"ADMIN": {"query", "read"}}, "otp_sessions": {"ADMIN": {"query", "read"}},
@ -172,6 +187,16 @@ def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками") raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками")
def _request_for_related_row_or_404(db: Session, row: Any) -> Request:
request_id = getattr(row, "request_id", None)
if request_id is None:
raise HTTPException(status_code=400, detail="Связанная заявка не найдена")
req = db.get(Request, request_id)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
return req
def _serialize_value(value: Any) -> Any: def _serialize_value(value: Any) -> Any:
if isinstance(value, dict): if isinstance(value, dict):
return {key: _serialize_value(val) for key, val in value.items()} return {key: _serialize_value(val) for key, val in value.items()}
@ -231,6 +256,8 @@ def _table_label(table_name: str) -> str:
"topics": "Темы", "topics": "Темы",
"statuses": "Статусы", "statuses": "Статусы",
"form_fields": "Поля формы", "form_fields": "Поля формы",
"clients": "Клиенты",
"table_availability": "Доступность таблиц",
"topic_required_fields": "Обязательные поля темы", "topic_required_fields": "Обязательные поля темы",
"topic_data_templates": "Шаблоны данных темы", "topic_data_templates": "Шаблоны данных темы",
"topic_status_transitions": "Переходы статусов темы", "topic_status_transitions": "Переходы статусов темы",
@ -341,6 +368,7 @@ def _column_label(table_name: str, column_name: str) -> str:
"amount": "Сумма", "amount": "Сумма",
"currency": "Валюта", "currency": "Валюта",
"client_name": "Клиент", "client_name": "Клиент",
"client_id": "Клиент (ID)",
"client_phone": "Телефон", "client_phone": "Телефон",
"payer_display_name": "Плательщик", "payer_display_name": "Плательщик",
"payer_details_encrypted": "Реквизиты (шифр.)", "payer_details_encrypted": "Реквизиты (шифр.)",
@ -401,6 +429,7 @@ def _column_label(table_name: str, column_name: str) -> str:
"reason": "Причина", "reason": "Причина",
"diff": "Изменения", "diff": "Изменения",
"details": "Детали", "details": "Детали",
"table_name": "Таблица",
} }
if normalized_column in explicit: if normalized_column in explicit:
return explicit[normalized_column] return explicit[normalized_column]
@ -459,6 +488,10 @@ def _hidden_response_fields(table_name: str) -> set[str]:
def _protected_input_fields(table_name: str) -> set[str]: def _protected_input_fields(table_name: str) -> set[str]:
if table_name == "admin_users": if table_name == "admin_users":
return {"password_hash"} return {"password_hash"}
if table_name == "requests":
return {"client_id", *REQUEST_CALCULATED_FIELDS}
if table_name == "invoices":
return {"client_id", *INVOICE_CALCULATED_FIELDS}
return set() return set()
@ -552,6 +585,52 @@ def _normalize_optional_string(value: Any) -> str | None:
return text or None return text or None
def _normalize_client_phone(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
allowed = {"+", "(", ")", "-", " "}
return "".join(ch for ch in text if ch.isdigit() or ch in allowed).strip()
def _upsert_client_or_400(db: Session, *, full_name: Any, phone: Any, responsible: str) -> Client:
normalized_phone = _normalize_client_phone(phone)
if not normalized_phone:
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно')
normalized_name = str(full_name or "").strip() or "Клиент"
row = db.query(Client).filter(Client.phone == normalized_phone).first()
if row is None:
row = Client(
full_name=normalized_name,
phone=normalized_phone,
responsible=responsible or "Администратор системы",
)
db.add(row)
db.flush()
return row
changed = False
if normalized_name and row.full_name != normalized_name:
row.full_name = normalized_name
changed = True
if responsible and row.responsible != responsible:
row.responsible = responsible
changed = True
if changed:
db.add(row)
db.flush()
return row
def _request_for_uuid_or_400(db: Session, raw_request_id: Any) -> Request:
request_uuid = _parse_uuid_or_400(raw_request_id, "request_id")
req = db.get(Request, request_uuid)
if req is None:
raise HTTPException(status_code=400, detail="Заявка не найдена")
return req
def _active_lawyer_or_400(db: Session, lawyer_id: Any) -> AdminUser: def _active_lawyer_or_400(db: Session, lawyer_id: Any) -> AdminUser:
lawyer_uuid = _parse_uuid_or_400(lawyer_id, "assigned_lawyer_id") lawyer_uuid = _parse_uuid_or_400(lawyer_id, "assigned_lawyer_id")
lawyer = db.get(AdminUser, lawyer_uuid) lawyer = db.get(AdminUser, lawyer_uuid)
@ -955,23 +1034,49 @@ def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin:
) )
@router.get("/meta/tables") def _table_section(table_name: str) -> str:
def list_tables_meta(admin: dict = Depends(get_current_admin)): if table_name in {"requests", "invoices"}:
role = str(admin.get("role") or "").upper() return "main"
if role != "ADMIN": if table_name == "table_availability":
raise HTTPException(status_code=403, detail="Недостаточно прав") return "system"
return "dictionary"
def _table_availability_map(db: Session) -> dict[str, TableAvailability]:
rows = db.query(TableAvailability).all()
return {str(row.table_name): row for row in rows if row and row.table_name}
def _table_is_active(table_name: str, availability: dict[str, TableAvailability]) -> bool:
row = availability.get(table_name)
if row is None:
return True
return bool(row.is_active)
def _meta_tables_payload(
db: Session,
*,
role: str,
include_inactive_dictionaries: bool,
) -> list[dict[str, Any]]:
table_models = _table_model_map() table_models = _table_model_map()
availability = _table_availability_map(db)
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
for table_name in sorted(table_models.keys()): for table_name in sorted(table_models.keys()):
model = table_models[table_name] model = table_models[table_name]
section = _table_section(table_name)
is_active = _table_is_active(table_name, availability)
if section == "dictionary" and not include_inactive_dictionaries and not is_active:
continue
actions = sorted(_allowed_actions(role, table_name)) actions = sorted(_allowed_actions(role, table_name))
rows.append( rows.append(
{ {
"key": table_name, "key": table_name,
"table": table_name, "table": table_name,
"label": _table_label(table_name), "label": _table_label(table_name),
"section": "main" if table_name in {"requests", "invoices"} else "dictionary", "section": section,
"is_active": is_active,
"actions": actions, "actions": actions,
"query_endpoint": f"/api/admin/crud/{table_name}/query", "query_endpoint": f"/api/admin/crud/{table_name}/query",
"create_endpoint": f"/api/admin/crud/{table_name}", "create_endpoint": f"/api/admin/crud/{table_name}",
@ -981,8 +1086,80 @@ def list_tables_meta(admin: dict = Depends(get_current_admin)):
"columns": _table_columns_meta(table_name, model), "columns": _table_columns_meta(table_name, model),
} }
) )
return rows
return {"tables": rows}
class TableAvailabilityUpdatePayload(BaseModel):
is_active: bool
@router.get("/meta/tables")
def list_tables_meta(db: Session = Depends(get_db), admin: dict = Depends(get_current_admin)):
role = str(admin.get("role") or "").upper()
if role != "ADMIN":
raise HTTPException(status_code=403, detail="Недостаточно прав")
return {"tables": _meta_tables_payload(db, role=role, include_inactive_dictionaries=False)}
@router.get("/meta/available-tables")
def list_available_tables(db: Session = Depends(get_db), admin: dict = Depends(get_current_admin)):
role = str(admin.get("role") or "").upper()
if role != "ADMIN":
raise HTTPException(status_code=403, detail="Недостаточно прав")
availability = _table_availability_map(db)
rows = []
for item in _meta_tables_payload(db, role=role, include_inactive_dictionaries=True):
table_name = str(item.get("table") or "")
state = availability.get(table_name)
rows.append(
{
"table": table_name,
"label": item.get("label"),
"section": item.get("section"),
"is_active": bool(item.get("is_active")),
"responsible": state.responsible if state is not None else None,
"updated_at": _serialize_value(state.updated_at) if state is not None else None,
}
)
return {"rows": rows, "total": len(rows)}
@router.patch("/meta/available-tables/{table_name}")
def update_available_table(
table_name: str,
payload: TableAvailabilityUpdatePayload,
db: Session = Depends(get_db),
admin: dict = Depends(get_current_admin),
):
role = str(admin.get("role") or "").upper()
if role != "ADMIN":
raise HTTPException(status_code=403, detail="Недостаточно прав")
normalized, _ = _resolve_table_model(table_name)
row = db.query(TableAvailability).filter(TableAvailability.table_name == normalized).first()
responsible = _resolve_responsible(admin)
is_active = bool(payload.is_active)
if row is None:
row = TableAvailability(
table_name=normalized,
is_active=is_active,
responsible=responsible,
)
db.add(row)
else:
row.is_active = is_active
row.updated_at = datetime.now(timezone.utc)
row.responsible = responsible
db.add(row)
db.commit()
db.refresh(row)
return {
"table": normalized,
"is_active": bool(row.is_active),
"responsible": row.responsible,
"updated_at": _serialize_value(row.updated_at),
}
@router.post("/{table_name}/query") @router.post("/{table_name}/query")
@ -1003,6 +1180,22 @@ def query_table(
Request.assigned_lawyer_id.is_(None), Request.assigned_lawyer_id.is_(None),
) )
) )
if normalized == "messages" and _is_lawyer(admin):
actor_id = _lawyer_actor_id_or_401(admin)
base_query = base_query.join(Request, Request.id == Message.request_id).filter(
or_(
Request.assigned_lawyer_id == actor_id,
Request.assigned_lawyer_id.is_(None),
)
)
if normalized == "attachments" and _is_lawyer(admin):
actor_id = _lawyer_actor_id_or_401(admin)
base_query = base_query.join(Request, Request.id == Attachment.request_id).filter(
or_(
Request.assigned_lawyer_id == actor_id,
Request.assigned_lawyer_id.is_(None),
)
)
query = apply_universal_query(base_query, model, uq) query = apply_universal_query(base_query, model, uq)
total = query.count() total = query.count()
rows = query.offset(uq.page.offset).limit(uq.page.limit).all() rows = query.offset(uq.page.offset).limit(uq.page.limit).all()
@ -1039,6 +1232,12 @@ def get_row(
db.commit() db.commit()
db.refresh(req) db.refresh(req)
row = req row = req
if normalized == "messages" and isinstance(row, Message):
req = _request_for_related_row_or_404(db, row)
_ensure_lawyer_can_view_request_or_403(admin, req)
if normalized == "attachments" and isinstance(row, Attachment):
req = _request_for_related_row_or_404(db, row)
_ensure_lawyer_can_view_request_or_403(admin, req)
return _strip_hidden_fields(normalized, _row_to_dict(row)) return _strip_hidden_fields(normalized, _row_to_dict(row))
@ -1051,6 +1250,9 @@ def create_row(
): ):
normalized, model = _resolve_table_model(table_name) normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "create") _require_table_action(admin, normalized, "create")
responsible = _resolve_responsible(admin)
resolved_request_client_id: uuid.UUID | None = None
resolved_invoice_client_id: uuid.UUID | None = None
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict): if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
assigned_lawyer_id = payload.get("assigned_lawyer_id") assigned_lawyer_id = payload.get("assigned_lawyer_id")
if str(assigned_lawyer_id or "").strip(): if str(assigned_lawyer_id or "").strip():
@ -1060,8 +1262,28 @@ def create_row(
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки") raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
prepared = _prepare_create_payload(normalized, payload) prepared = _prepare_create_payload(normalized, payload)
if normalized == "messages":
request_uuid = _parse_uuid_or_400(prepared.get("request_id"), "request_id")
req = db.get(Request, request_uuid)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
if _is_lawyer(admin):
_ensure_lawyer_can_manage_request_or_403(admin, req)
prepared["author_type"] = "LAWYER"
prepared["author_name"] = str(admin.get("email") or "").strip() or "Юрист"
prepared["immutable"] = False
prepared["request_id"] = request_uuid
if normalized == "requests": if normalized == "requests":
validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields")) validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields"))
client = _upsert_client_or_400(
db,
full_name=prepared.get("client_name"),
phone=prepared.get("client_phone"),
responsible=responsible,
)
resolved_request_client_id = client.id
prepared["client_name"] = client.full_name
prepared["client_phone"] = client.phone
if not _is_lawyer(admin): if not _is_lawyer(admin):
assigned_raw = prepared.get("assigned_lawyer_id") assigned_raw = prepared.get("assigned_lawyer_id")
if assigned_raw is None or not str(assigned_raw).strip(): if assigned_raw is None or not str(assigned_raw).strip():
@ -1072,6 +1294,10 @@ def create_row(
prepared["assigned_lawyer_id"] = str(assigned_lawyer.id) prepared["assigned_lawyer_id"] = str(assigned_lawyer.id)
if prepared.get("effective_rate") is None: if prepared.get("effective_rate") is None:
prepared["effective_rate"] = assigned_lawyer.default_rate prepared["effective_rate"] = assigned_lawyer.default_rate
if normalized == "invoices":
req = _request_for_uuid_or_400(db, prepared.get("request_id"))
prepared["request_id"] = req.id
resolved_invoice_client_id = req.client_id
prepared = _apply_auto_fields_for_create(db, model, normalized, prepared) prepared = _apply_auto_fields_for_create(db, model, normalized, prepared)
clean_payload = _sanitize_payload( clean_payload = _sanitize_payload(
model, model,
@ -1092,8 +1318,12 @@ def create_row(
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload) clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
if normalized == "statuses": if normalized == "statuses":
clean_payload = _apply_status_fields(clean_payload) clean_payload = _apply_status_fields(clean_payload)
if normalized == "requests":
clean_payload["client_id"] = resolved_request_client_id
if normalized == "invoices":
clean_payload["client_id"] = resolved_invoice_client_id
if "responsible" in _columns_map(model): if "responsible" in _columns_map(model):
clean_payload["responsible"] = _resolve_responsible(admin) clean_payload["responsible"] = responsible
row = model(**clean_payload) row = model(**clean_payload)
try: try:
@ -1121,6 +1351,7 @@ def update_row(
): ):
normalized, model = _resolve_table_model(table_name) normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "update") _require_table_action(admin, normalized, "update")
responsible = _resolve_responsible(admin)
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict): if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
if "assigned_lawyer_id" in payload: if "assigned_lawyer_id" in payload:
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"') raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
@ -1154,6 +1385,26 @@ def update_row(
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload) clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
if normalized == "statuses": if normalized == "statuses":
clean_payload = _apply_status_fields(clean_payload) clean_payload = _apply_status_fields(clean_payload)
if normalized == "requests" and isinstance(row, Request):
if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None:
client = _upsert_client_or_400(
db,
full_name=clean_payload.get("client_name", row.client_name),
phone=clean_payload.get("client_phone", row.client_phone),
responsible=responsible,
)
clean_payload["client_id"] = client.id
clean_payload["client_name"] = client.full_name
clean_payload["client_phone"] = client.phone
if normalized == "invoices":
if "request_id" in clean_payload:
req = _request_for_uuid_or_400(db, clean_payload.get("request_id"))
clean_payload["request_id"] = req.id
clean_payload["client_id"] = req.client_id
elif getattr(row, "client_id", None) is None:
req = db.get(Request, getattr(row, "request_id", None))
if req is not None:
clean_payload["client_id"] = req.client_id
if normalized == "requests" and not _is_lawyer(admin) and "assigned_lawyer_id" in clean_payload: if normalized == "requests" and not _is_lawyer(admin) and "assigned_lawyer_id" in clean_payload:
assigned_raw = clean_payload.get("assigned_lawyer_id") assigned_raw = clean_payload.get("assigned_lawyer_id")
if assigned_raw is None or not str(assigned_raw).strip(): if assigned_raw is None or not str(assigned_raw).strip():
@ -1163,6 +1414,8 @@ def update_row(
clean_payload["assigned_lawyer_id"] = str(assigned_lawyer.id) clean_payload["assigned_lawyer_id"] = str(assigned_lawyer.id)
if isinstance(row, Request) and row.effective_rate is None and "effective_rate" not in clean_payload: if isinstance(row, Request) and row.effective_rate is None and "effective_rate" not in clean_payload:
clean_payload["effective_rate"] = assigned_lawyer.default_rate clean_payload["effective_rate"] = assigned_lawyer.default_rate
if "responsible" in _columns_map(model):
clean_payload["responsible"] = responsible
before = _row_to_dict(row) before = _row_to_dict(row)
if normalized == "topic_status_transitions": if normalized == "topic_status_transitions":
next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip() next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip()
@ -1185,7 +1438,7 @@ def update_row(
from_status=before_status, from_status=before_status,
to_status=after_status, to_status=after_status,
admin=admin, admin=admin,
responsible=_resolve_responsible(admin), responsible=responsible,
) )
mark_unread_for_client(row, EVENT_STATUS) mark_unread_for_client(row, EVENT_STATUS)
apply_status_change_effects( apply_status_change_effects(
@ -1194,7 +1447,7 @@ def update_row(
from_status=before_status, from_status=before_status,
to_status=after_status, to_status=after_status,
admin=admin, admin=admin,
responsible=_resolve_responsible(admin), responsible=responsible,
) )
notify_request_event( notify_request_event(
db, db,
@ -1203,7 +1456,7 @@ def update_row(
actor_role=_actor_role(admin), actor_role=_actor_role(admin),
actor_admin_user_id=admin.get("sub"), actor_admin_user_id=admin.get("sub"),
body=(f"{before_status} -> {after_status}" + (f"\n{billing_note}" if billing_note else "")), body=(f"{before_status} -> {after_status}" + (f"\n{billing_note}" if billing_note else "")),
responsible=_resolve_responsible(admin), responsible=responsible,
) )
for key, value in clean_payload.items(): for key, value in clean_payload.items():
setattr(row, key, value) setattr(row, key, value)

View file

@ -134,6 +134,7 @@ def _serialize_invoice(
"id": str(row.id), "id": str(row.id),
"invoice_number": row.invoice_number, "invoice_number": row.invoice_number,
"request_id": str(row.request_id), "request_id": str(row.request_id),
"client_id": str(row.client_id) if row.client_id else None,
"request_track_number": request_track, "request_track_number": request_track,
"status": row.status, "status": row.status,
"status_label": STATUS_LABELS.get(str(row.status or "").upper(), row.status), "status_label": STATUS_LABELS.get(str(row.status or "").upper(), row.status),
@ -275,6 +276,7 @@ def create_invoice(
invoice = Invoice( invoice = Invoice(
request_id=req.id, request_id=req.id,
client_id=req.client_id,
invoice_number=str(payload.get("invoice_number") or "").strip() or _invoice_number(db), invoice_number=str(payload.get("invoice_number") or "").strip() or _invoice_number(db),
status=status, status=status,
amount=_amount_or_400(payload.get("amount")), amount=_amount_or_400(payload.get("amount")),

View file

@ -20,7 +20,10 @@ from app.models.admin_user import AdminUser
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.request_data_requirement import RequestDataRequirement from app.models.request_data_requirement import RequestDataRequirement
from app.models.request import Request from app.models.request import Request
from app.models.status import Status
from app.models.status_history import StatusHistory
from app.models.topic_data_template import TopicDataTemplate from app.models.topic_data_template import TopicDataTemplate
from app.models.topic_status_transition import TopicStatusTransition
from app.services.notifications import ( from app.services.notifications import (
EVENT_STATUS as NOTIFICATION_EVENT_STATUS, EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
mark_admin_notifications_read, mark_admin_notifications_read,
@ -317,6 +320,174 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re
} }
@router.get("/{request_id}/status-route")
def get_request_status_route(
request_id: str,
db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER")),
):
request_uuid = _request_uuid_or_400(request_id)
req = db.get(Request, request_uuid)
if not req:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_can_view_request_or_403(admin, req)
topic_code = str(req.topic_code or "").strip()
current_status = str(req.status_code or "").strip()
transitions: list[TopicStatusTransition] = []
if topic_code:
transitions = (
db.query(TopicStatusTransition)
.filter(
TopicStatusTransition.topic_code == topic_code,
TopicStatusTransition.enabled.is_(True),
)
.order_by(
TopicStatusTransition.sort_order.asc(),
TopicStatusTransition.from_status.asc(),
TopicStatusTransition.to_status.asc(),
)
.all()
)
history_rows = (
db.query(StatusHistory)
.filter(StatusHistory.request_id == req.id)
.order_by(StatusHistory.created_at.asc())
.all()
)
known_codes: set[str] = set()
if current_status:
known_codes.add(current_status)
for row in history_rows:
from_code = str(row.from_status or "").strip()
to_code = str(row.to_status or "").strip()
if from_code:
known_codes.add(from_code)
if to_code:
known_codes.add(to_code)
for row in transitions:
from_code = str(row.from_status or "").strip()
to_code = str(row.to_status or "").strip()
if from_code:
known_codes.add(from_code)
if to_code:
known_codes.add(to_code)
statuses_map: dict[str, dict[str, str]] = {}
if known_codes:
status_rows = db.query(Status).filter(Status.code.in_(list(known_codes))).all()
statuses_map = {
str(row.code): {
"name": str(row.name or row.code),
"kind": str(row.kind or "DEFAULT"),
}
for row in status_rows
}
sequence_from_history: list[str] = []
if history_rows:
first_from = str(history_rows[0].from_status or "").strip()
if first_from:
sequence_from_history.append(first_from)
for row in history_rows:
to_code = str(row.to_status or "").strip()
if to_code:
sequence_from_history.append(to_code)
elif current_status:
sequence_from_history.append(current_status)
ordered_codes: list[str] = []
seen_codes: set[str] = set()
def add_code(code: str) -> None:
normalized = str(code or "").strip()
if not normalized or normalized in seen_codes:
return
seen_codes.add(normalized)
ordered_codes.append(normalized)
for code in sequence_from_history:
add_code(code)
for row in transitions:
add_code(str(row.from_status or ""))
add_code(str(row.to_status or ""))
add_code(current_status)
transition_by_to_status: dict[str, dict[str, str | int | None]] = {}
for row in transitions:
to_code = str(row.to_status or "").strip()
if not to_code:
continue
current = transition_by_to_status.get(to_code)
if current is None or int(row.sort_order or 0) < int(current.get("sort_order") or 0):
transition_by_to_status[to_code] = {
"from_status": str(row.from_status or "").strip() or None,
"sla_hours": row.sla_hours,
"sort_order": int(row.sort_order or 0),
}
changed_at_by_status: dict[str, str] = {}
for row in history_rows:
to_code = str(row.to_status or "").strip()
if to_code and row.created_at:
changed_at_by_status[to_code] = row.created_at.isoformat()
visited_codes = {code for code in sequence_from_history if code}
current_index = ordered_codes.index(current_status) if current_status in ordered_codes else -1
def status_name(code: str) -> str:
meta = statuses_map.get(code) or {}
return str(meta.get("name") or code)
nodes: list[dict[str, str | int | None]] = []
for index, code in enumerate(ordered_codes):
meta = statuses_map.get(code) or {}
transition_meta = transition_by_to_status.get(code) or {}
state = "pending"
if code == current_status:
state = "current"
elif code in visited_codes or (current_index >= 0 and index < current_index):
state = "completed"
note_parts: list[str] = []
from_status = transition_meta.get("from_status")
if from_status:
note_parts.append(f"Переход из статуса «{status_name(str(from_status))}»")
sla_hours = transition_meta.get("sla_hours")
if sla_hours is not None:
note_parts.append(f"SLA: {sla_hours} ч")
kind = str(meta.get("kind") or "DEFAULT")
if kind == "INVOICE":
note_parts.append("Этап выставления счета")
elif kind == "PAID":
note_parts.append("Этап подтверждения оплаты")
nodes.append(
{
"code": code,
"name": status_name(code),
"kind": kind,
"state": state,
"sla_hours": sla_hours,
"changed_at": changed_at_by_status.get(code),
"note": "".join(note_parts),
}
)
return {
"request_id": str(req.id),
"track_number": req.track_number,
"topic_code": req.topic_code,
"current_status": current_status or None,
"nodes": nodes,
}
@router.post("/{request_id}/claim") @router.post("/{request_id}/claim")
def claim_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("LAWYER"))): def claim_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("LAWYER"))):
request_uuid = _request_uuid_or_400(request_id) request_uuid = _request_uuid_or_400(request_id)

View file

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

76
app/api/public/chat.py Normal file
View file

@ -0,0 +1,76 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.deps import get_public_session
from app.db.session import get_db
from app.models.request import Request
from app.schemas.public import PublicMessageCreate
from app.services.chat_service import create_client_message, list_messages_for_request, serialize_message
router = APIRouter()
def _normalize_phone(raw: str | None) -> str:
value = str(raw or "").strip()
if not value:
return ""
allowed = {"+", "(", ")", "-", " "}
return "".join(ch for ch in value if ch.isdigit() or ch in allowed).strip()
def _normalize_track(raw: str | None) -> str:
return str(raw or "").strip().upper()
def _require_view_session_or_403(session: dict) -> str:
purpose = str(session.get("purpose") or "").strip().upper()
subject = str(session.get("sub") or "").strip()
if purpose != "VIEW_REQUEST" or not subject:
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
return subject
def _request_for_track_or_404(db: Session, track_number: str) -> Request:
req = db.query(Request).filter(Request.track_number == _normalize_track(track_number)).first()
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
return req
def _ensure_view_access_or_403(session: dict, req: Request) -> None:
subject = _require_view_session_or_403(session)
subject_track = _normalize_track(subject)
if subject_track.startswith("TRK-") and subject_track != _normalize_track(req.track_number):
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
if subject_track == _normalize_track(req.track_number):
return
if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(req.client_phone):
return
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
@router.get("/requests/{track_number}/messages")
def list_messages_by_track(
track_number: str,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, track_number)
_ensure_view_access_or_403(session, req)
rows = list_messages_for_request(db, req.id)
return [serialize_message(row) for row in rows]
@router.post("/requests/{track_number}/messages", status_code=201)
def create_message_by_track(
track_number: str,
payload: PublicMessageCreate,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, track_number)
_ensure_view_access_or_403(session, req)
row = create_client_message(db, request=req, body=payload.body)
return serialize_message(row)

View file

@ -136,12 +136,18 @@ def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)):
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST') raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
else: else:
track_number = _normalize_track(payload.track_number) track_number = _normalize_track(payload.track_number)
if not track_number: phone = _normalize_phone(payload.client_phone)
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST') if track_number:
request_row = db.query(RequestModel).filter(RequestModel.track_number == track_number).first() request_row = db.query(RequestModel).filter(RequestModel.track_number == track_number).first()
if request_row is None: if request_row is None:
raise HTTPException(status_code=404, detail="Заявка не найдена") raise HTTPException(status_code=404, detail="Заявка не найдена")
phone = _normalize_phone(request_row.client_phone) phone = _normalize_phone(request_row.client_phone)
elif phone:
has_requests = db.query(RequestModel.id).filter(RequestModel.client_phone == phone).first()
if has_requests is None:
raise HTTPException(status_code=404, detail="Заявки по номеру телефона не найдены")
else:
raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_phone"')
if not phone: if not phone:
raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона") raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона")
@ -201,8 +207,9 @@ def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Ses
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST') raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
else: else:
track_number = _normalize_track(payload.track_number) track_number = _normalize_track(payload.track_number)
if not track_number: phone = _normalize_phone(payload.client_phone)
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST') if not track_number and not phone:
raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_phone"')
_rate_limit_or_429( _rate_limit_or_429(
"verify", "verify",
@ -212,11 +219,10 @@ def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Ses
track_number=track_number, track_number=track_number,
) )
query = db.query(OtpSession).filter( query = db.query(OtpSession).filter(OtpSession.purpose == purpose)
OtpSession.purpose == purpose, if track_number is not None and track_number != "":
OtpSession.track_number == track_number, query = query.filter(OtpSession.track_number == track_number)
) if phone is not None and phone != "":
if phone is not None:
query = query.filter(OtpSession.phone == phone) query = query.filter(OtpSession.phone == phone)
row = query.order_by(OtpSession.created_at.desc()).first() row = query.order_by(OtpSession.created_at.desc()).first()
@ -239,7 +245,15 @@ def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Ses
db.commit() db.commit()
raise HTTPException(status_code=400, detail="Неверный OTP-код") raise HTTPException(status_code=400, detail="Неверный OTP-код")
subject = row.phone if purpose == OTP_CREATE_PURPOSE else str(row.track_number or "") if purpose == OTP_CREATE_PURPOSE:
subject = str(row.phone or "")
else:
if phone:
subject = str(row.phone or "")
elif track_number:
subject = str(row.track_number or "")
else:
subject = str(row.phone or row.track_number or "")
if not subject: if not subject:
raise HTTPException(status_code=400, detail="Некорректная OTP-сессия") raise HTTPException(status_code=400, detail="Некорректная OTP-сессия")

View file

@ -14,21 +14,22 @@ from app.core.security import create_jwt
from app.db.session import get_db from app.db.session import get_db
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.attachment import Attachment from app.models.attachment import Attachment
from app.models.client import Client
from app.models.invoice import Invoice from app.models.invoice import Invoice
from app.models.message import Message from app.models.message import Message
from app.models.request import Request from app.models.request import Request
from app.models.status_history import StatusHistory 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_crypto import decrypt_requisites
from app.services.invoice_pdf import build_invoice_pdf_bytes from app.services.invoice_pdf import build_invoice_pdf_bytes
from app.services.chat_service import create_client_message, list_messages_for_request
from app.services.notifications import ( from app.services.notifications import (
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
get_client_notification, get_client_notification,
list_client_notifications, list_client_notifications,
mark_client_notifications_read, mark_client_notifications_read,
notify_request_event,
serialize_notification, serialize_notification,
) )
from app.services.request_read_markers import EVENT_MESSAGE, clear_unread_for_client, mark_unread_for_lawyer from app.services.request_read_markers import clear_unread_for_client
from app.services.request_templates import validate_required_topic_fields_or_400 from app.services.request_templates import validate_required_topic_fields_or_400
from app.schemas.public import ( from app.schemas.public import (
PublicAttachmentRead, PublicAttachmentRead,
@ -52,16 +53,20 @@ INVOICE_STATUS_LABELS = {
def _normalize_phone(raw: str | None) -> str: def _normalize_phone(raw: str | None) -> str:
return str(raw or "").strip() value = str(raw or "").strip()
if not value:
return ""
allowed = {"+", "(", ")", "-", " "}
return "".join(ch for ch in value if ch.isdigit() or ch in allowed).strip()
def _normalize_track(raw: str | None) -> str: def _normalize_track(raw: str | None) -> str:
return str(raw or "").strip().upper() return str(raw or "").strip().upper()
def _set_view_cookie(response: Response, track_number: str) -> None: def _set_view_cookie(response: Response, subject: str) -> None:
token = create_jwt( token = create_jwt(
{"sub": track_number, "purpose": OTP_VIEW_PURPOSE}, {"sub": subject, "purpose": OTP_VIEW_PURPOSE},
settings.PUBLIC_JWT_SECRET, settings.PUBLIC_JWT_SECRET,
timedelta(days=settings.PUBLIC_JWT_TTL_DAYS), timedelta(days=settings.PUBLIC_JWT_TTL_DAYS),
) )
@ -82,22 +87,60 @@ def _require_create_session_or_403(session: dict, client_phone: str) -> None:
raise HTTPException(status_code=403, detail="Требуется подтверждение телефона через OTP") raise HTTPException(status_code=403, detail="Требуется подтверждение телефона через OTP")
def _require_view_session_for_track_or_403(session: dict, track_number: str) -> None: def _require_view_session_or_403(session: dict) -> str:
purpose = str(session.get("purpose") or "").strip().upper() purpose = str(session.get("purpose") or "").strip().upper()
sub = _normalize_track(session.get("sub")) subject = str(session.get("sub") or "").strip()
if purpose != OTP_VIEW_PURPOSE or not sub or sub != _normalize_track(track_number): if purpose != OTP_VIEW_PURPOSE or not subject:
raise HTTPException(status_code=403, detail="Нет доступа к заявке") raise HTTPException(status_code=403, detail="Нет доступа к заявке")
return subject
def _ensure_view_access_or_403(session: dict, req: Request) -> None:
subject = _require_view_session_or_403(session)
if _normalize_track(subject) == _normalize_track(req.track_number):
return
if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(req.client_phone):
return
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
def _request_for_track_or_404(db: Session, session: dict, track_number: str) -> Request: def _request_for_track_or_404(db: Session, session: dict, track_number: str) -> Request:
normalized_track = _normalize_track(track_number) normalized_track = _normalize_track(track_number)
_require_view_session_for_track_or_403(session, normalized_track) subject = _require_view_session_or_403(session)
subject_track = _normalize_track(subject)
if subject_track.startswith("TRK-") and subject_track != normalized_track:
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
req = db.query(Request).filter(Request.track_number == normalized_track).first() req = db.query(Request).filter(Request.track_number == normalized_track).first()
if req is None: if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена") raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_view_access_or_403(session, req)
return req return req
def _upsert_client_by_phone(db: Session, *, full_name: str, phone: str) -> Client:
normalized_phone = _normalize_phone(phone)
if not normalized_phone:
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно')
normalized_name = str(full_name or "").strip() or "Клиент"
client = db.query(Client).filter(Client.phone == normalized_phone).first()
if client is None:
client = Client(
full_name=normalized_name,
phone=normalized_phone,
responsible="Клиент",
)
db.add(client)
db.flush()
return client
if client.full_name != normalized_name:
client.full_name = normalized_name
client.responsible = "Клиент"
db.add(client)
db.flush()
return client
def _to_iso(value) -> str | None: def _to_iso(value) -> str | None:
return value.isoformat() if value is not None else None return value.isoformat() if value is not None else None
@ -127,12 +170,14 @@ def create_request(
): ):
_require_create_session_or_403(session, payload.client_phone) _require_create_session_or_403(session, payload.client_phone)
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields) validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
client = _upsert_client_by_phone(db, full_name=payload.client_name, phone=payload.client_phone)
track = f"TRK-{uuid4().hex[:10].upper()}" track = f"TRK-{uuid4().hex[:10].upper()}"
row = Request( row = Request(
track_number=track, track_number=track,
client_name=payload.client_name, client_id=client.id,
client_phone=payload.client_phone, client_name=client.full_name,
client_phone=client.phone,
topic_code=payload.topic_code, topic_code=payload.topic_code,
description=payload.description, description=payload.description,
extra_fields=payload.extra_fields, extra_fields=payload.extra_fields,
@ -142,10 +187,55 @@ def create_request(
db.commit() db.commit()
db.refresh(row) db.refresh(row)
_set_view_cookie(response, track) _set_view_cookie(response, client.phone)
return PublicRequestCreated(request_id=row.id, track_number=row.track_number, otp_required=False) return PublicRequestCreated(request_id=row.id, track_number=row.track_number, otp_required=False)
@router.get("/topics")
def list_public_topics(db: Session = Depends(get_db)):
rows = (
db.query(Topic)
.filter(Topic.enabled.is_(True))
.order_by(Topic.sort_order.asc(), Topic.name.asc(), Topic.code.asc())
.all()
)
return [{"code": row.code, "name": row.name} for row in rows]
@router.get("/my")
def list_my_requests(
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
subject = _require_view_session_or_403(session)
normalized_track = _normalize_track(subject)
normalized_phone = _normalize_phone(subject)
query = db.query(Request)
if normalized_track.startswith("TRK-"):
query = query.filter(Request.track_number == normalized_track)
else:
query = query.filter(Request.client_phone == normalized_phone)
rows = query.order_by(Request.updated_at.desc(), Request.created_at.desc(), Request.id.desc()).all()
return {
"rows": [
{
"id": str(row.id),
"track_number": row.track_number,
"topic_code": row.topic_code,
"status_code": row.status_code,
"client_has_unread_updates": bool(row.client_has_unread_updates),
"client_unread_event_type": row.client_unread_event_type,
"created_at": _to_iso(row.created_at),
"updated_at": _to_iso(row.updated_at),
}
for row in rows
],
"total": len(rows),
}
@router.get("/{track_number}") @router.get("/{track_number}")
def get_request_by_track( def get_request_by_track(
track_number: str, track_number: str,
@ -167,6 +257,7 @@ def get_request_by_track(
return { return {
"id": str(req.id), "id": str(req.id),
"client_id": str(req.client_id) if req.client_id else None,
"track_number": req.track_number, "track_number": req.track_number,
"client_name": req.client_name, "client_name": req.client_name,
"client_phone": req.client_phone, "client_phone": req.client_phone,
@ -191,12 +282,7 @@ def list_messages_by_track(
session: dict = Depends(get_public_session), session: dict = Depends(get_public_session),
): ):
req = _request_for_track_or_404(db, session, track_number) req = _request_for_track_or_404(db, session, track_number)
rows = ( rows = list_messages_for_request(db, req.id)
db.query(Message)
.filter(Message.request_id == req.id)
.order_by(Message.created_at.asc(), Message.id.asc())
.all()
)
return [ return [
PublicMessageRead( PublicMessageRead(
id=row.id, id=row.id,
@ -219,31 +305,7 @@ def create_message_by_track(
session: dict = Depends(get_public_session), session: dict = Depends(get_public_session),
): ):
req = _request_for_track_or_404(db, session, track_number) req = _request_for_track_or_404(db, session, track_number)
body = str(payload.body or "").strip() row = create_client_message(db, request=req, body=payload.body)
if not body:
raise HTTPException(status_code=400, detail='Поле "body" обязательно')
row = Message(
request_id=req.id,
author_type="CLIENT",
author_name=req.client_name,
body=body,
responsible="Клиент",
)
mark_unread_for_lawyer(req, EVENT_MESSAGE)
req.responsible = "Клиент"
notify_request_event(
db,
request=req,
event_type=NOTIFICATION_EVENT_MESSAGE,
actor_role="CLIENT",
body=body,
responsible="Клиент",
)
db.add(row)
db.add(req)
db.commit()
db.refresh(row)
return PublicMessageRead( return PublicMessageRead(
id=row.id, id=row.id,

View file

@ -1,8 +1,9 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.public import requests, otp, quotes, uploads from app.api.public import requests, otp, quotes, uploads, chat
router = APIRouter() router = APIRouter()
router.include_router(requests.router, prefix="/requests", tags=["Public"]) router.include_router(requests.router, prefix="/requests", tags=["Public"])
router.include_router(otp.router, prefix="/otp", tags=["Public"]) router.include_router(otp.router, prefix="/otp", tags=["Public"])
router.include_router(quotes.router, prefix="/quotes", tags=["Public"]) router.include_router(quotes.router, prefix="/quotes", tags=["Public"])
router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"]) router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"])
router.include_router(chat.router, prefix="/chat", tags=["PublicChat"])

View file

@ -48,10 +48,23 @@ def _ensure_public_request_access_or_403(request: Request, session: dict) -> Non
purpose = str(session.get("purpose") or "").strip().upper() purpose = str(session.get("purpose") or "").strip().upper()
if purpose != "VIEW_REQUEST": if purpose != "VIEW_REQUEST":
raise HTTPException(status_code=403, detail="Нет доступа к заявке") raise HTTPException(status_code=403, detail="Нет доступа к заявке")
track_from_session = str(session.get("sub") or "").strip() subject = str(session.get("sub") or "").strip()
if not track_from_session or track_from_session != str(request.track_number): if not subject:
raise HTTPException(status_code=403, detail="Нет доступа к заявке") raise HTTPException(status_code=403, detail="Нет доступа к заявке")
normalized_track = str(subject).strip().upper()
if normalized_track == str(request.track_number or "").strip().upper():
return
def _normalize_phone(value: str | None) -> str:
raw = str(value or "").strip()
allowed = {"+", "(", ")", "-", " "}
return "".join(ch for ch in raw if ch.isdigit() or ch in allowed).strip()
if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(request.client_phone):
return
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
def _load_attachment_with_access_or_4xx(attachment_id: str, db: Session, session: dict) -> Attachment: def _load_attachment_with_access_or_4xx(attachment_id: str, db: Session, session: dict) -> Attachment:
attachment_uuid = _uuid_or_400(attachment_id, "attachment_id") attachment_uuid = _uuid_or_400(attachment_id, "attachment_id")

View file

@ -32,6 +32,10 @@ class Settings(BaseSettings):
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300 OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
OTP_SEND_RATE_LIMIT: int = 8 OTP_SEND_RATE_LIMIT: int = 8
OTP_VERIFY_RATE_LIMIT: int = 20 OTP_VERIFY_RATE_LIMIT: int = 20
ADMIN_BOOTSTRAP_ENABLED: bool = True
ADMIN_BOOTSTRAP_EMAIL: str = "admin@example.com"
ADMIN_BOOTSTRAP_PASSWORD: str = "admin123"
ADMIN_BOOTSTRAP_NAME: str = "Администратор системы"
@property @property
def cors_origins_list(self) -> List[str]: def cors_origins_list(self) -> List[str]:

14
app/models/client.py Normal file
View file

@ -0,0 +1,14 @@
import uuid
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import TimestampMixin, UUIDMixin
class Client(Base, UUIDMixin, TimestampMixin):
__tablename__ = "clients"
full_name: Mapped[str] = mapped_column(String(200), nullable=False)
phone: Mapped[str] = mapped_column(String(30), nullable=False, unique=True, index=True)

View file

@ -13,6 +13,7 @@ class Invoice(Base, UUIDMixin, TimestampMixin):
__tablename__ = "invoices" __tablename__ = "invoices"
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
client_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True)
invoice_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False, index=True) invoice_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False, index=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, index=True, default="WAITING_PAYMENT") status: Mapped[str] = mapped_column(String(20), nullable=False, index=True, default="WAITING_PAYMENT")
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False) amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False)

View file

@ -1,6 +1,8 @@
from datetime import datetime from datetime import datetime
import uuid
from sqlalchemy import Boolean, DateTime, Integer, JSON, Numeric, String, Text from sqlalchemy import Boolean, DateTime, Integer, JSON, Numeric, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base from app.db.session import Base
from app.models.common import UUIDMixin, TimestampMixin from app.models.common import UUIDMixin, TimestampMixin
@ -8,6 +10,7 @@ from app.models.common import UUIDMixin, TimestampMixin
class Request(Base, UUIDMixin, TimestampMixin): class Request(Base, UUIDMixin, TimestampMixin):
__tablename__ = "requests" __tablename__ = "requests"
track_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False, index=True) track_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False, index=True)
client_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
client_name: Mapped[str] = mapped_column(String(200), nullable=False) client_name: Mapped[str] = mapped_column(String(200), nullable=False)
client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True) client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)

View file

@ -0,0 +1,12 @@
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import TimestampMixin, UUIDMixin
class TableAvailability(Base, UUIDMixin, TimestampMixin):
__tablename__ = "table_availability"
table_name: Mapped[str] = mapped_column(String(120), unique=True, index=True, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security import hash_password, verify_password
from app.models.admin_user import AdminUser
def normalize_admin_email(raw: str | None) -> str:
return str(raw or "").strip().lower()
def get_active_admin_by_email(db: Session, email: str) -> AdminUser | None:
normalized = normalize_admin_email(email)
if not normalized:
return None
return (
db.query(AdminUser)
.filter(func.lower(AdminUser.email) == normalized, AdminUser.is_active.is_(True))
.first()
)
def get_admin_by_email_any_state(db: Session, email: str) -> AdminUser | None:
normalized = normalize_admin_email(email)
if not normalized:
return None
return db.query(AdminUser).filter(func.lower(AdminUser.email) == normalized).first()
def ensure_bootstrap_admin_for_login(db: Session, email: str, password: str) -> AdminUser | None:
if not settings.ADMIN_BOOTSTRAP_ENABLED:
return None
normalized_email = normalize_admin_email(email)
bootstrap_email = normalize_admin_email(settings.ADMIN_BOOTSTRAP_EMAIL)
if normalized_email != bootstrap_email:
return None
if str(password or "") != str(settings.ADMIN_BOOTSTRAP_PASSWORD or ""):
return None
user = get_admin_by_email_any_state(db, bootstrap_email)
if user is None:
user = AdminUser(
role="ADMIN",
name=str(settings.ADMIN_BOOTSTRAP_NAME or "Администратор системы"),
email=bootstrap_email,
password_hash=hash_password(str(settings.ADMIN_BOOTSTRAP_PASSWORD or "")),
is_active=True,
)
db.add(user)
else:
user.role = "ADMIN"
user.email = bootstrap_email
user.is_active = True
if not str(user.name or "").strip():
user.name = str(settings.ADMIN_BOOTSTRAP_NAME or "Администратор системы")
if not verify_password(str(settings.ADMIN_BOOTSTRAP_PASSWORD or ""), str(user.password_hash or "")):
user.password_hash = hash_password(str(settings.ADMIN_BOOTSTRAP_PASSWORD or ""))
db.add(user)
try:
db.commit()
except IntegrityError:
db.rollback()
return get_active_admin_by_email(db, bootstrap_email)
db.refresh(user)
return user

View file

@ -0,0 +1,105 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models.message import Message
from app.models.request import Request
from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, notify_request_event
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer
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 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

View file

@ -90,8 +90,12 @@
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0.35rem;
padding-left: 0.6rem; padding-left: 0.6rem;
padding-right: 0.2rem;
border-left: 1px dashed rgba(212, 168, 106, 0.3); border-left: 1px dashed rgba(212, 168, 106, 0.3);
margin: 0.2rem 0 0.1rem 0.2rem; margin: 0.2rem 0 0.1rem 0.2rem;
max-height: 38vh;
overflow-y: auto;
overflow-x: hidden;
} }
.menu-tree button { .menu-tree button {
@ -210,6 +214,18 @@
font-size: 0.94rem; font-size: 0.94rem;
} }
.breadcrumbs {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.86rem;
line-height: 1.4;
}
.breadcrumbs b {
color: #dce7f7;
font-weight: 700;
}
.cards { .cards {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@ -607,6 +623,380 @@
word-break: break-word; word-break: break-word;
} }
.simple-list {
margin: 0;
padding: 0;
list-style: none;
}
.simple-list li {
padding: 0.52rem 0.55rem;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.025);
margin-bottom: 0.4rem;
font-size: 0.86rem;
}
.simple-list li:last-child {
margin-bottom: 0;
}
.request-modal {
width: min(1120px, 100%);
}
.request-modal-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.request-workspace-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
margin-bottom: 0.8rem;
flex-wrap: wrap;
}
.request-workspace-layout {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.9fr);
gap: 0.75rem;
align-items: start;
}
.request-main-column {
display: grid;
gap: 0.75rem;
}
.request-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.request-field {
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
padding: 0.5rem 0.55rem;
display: grid;
gap: 0.2rem;
}
.request-field-label {
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9fb0c5;
font-weight: 700;
}
.request-field-value {
font-size: 0.9rem;
color: #d8e5f7;
word-break: break-word;
line-height: 1.35;
}
.request-description-block,
.request-extra-block {
margin-top: 0.7rem;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
padding: 0.58rem;
}
.request-description-block p {
margin: 0.4rem 0 0;
color: #d8e5f7;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.9rem;
}
.request-extra-list {
margin-top: 0.45rem;
max-height: 220px;
overflow: auto;
}
.request-status-route {
margin-top: 0.85rem;
padding-top: 0.8rem;
border-top: 1px solid var(--line);
}
.request-status-route h4 {
margin: 0 0 0.7rem;
font-size: 0.96rem;
}
.request-route-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.2rem;
}
.route-item {
position: relative;
padding: 0 0 0.85rem 2.1rem;
}
.route-item::before {
content: "";
position: absolute;
left: 0.56rem;
top: 0.52rem;
bottom: -0.2rem;
width: 2px;
background: #5c6573;
opacity: 0.6;
}
.route-item:last-child::before {
display: none;
}
.route-dot {
position: absolute;
left: 0;
top: 0.28rem;
width: 1.12rem;
height: 1.12rem;
border-radius: 50%;
background: #818999;
border: 2px solid rgba(18, 30, 43, 0.95);
box-shadow: 0 0 0 1px rgba(129, 137, 153, 0.35);
}
.route-item.completed .route-dot,
.route-item.current .route-dot {
background: #3f72ff;
box-shadow: 0 0 0 1px rgba(63, 114, 255, 0.35);
}
.route-item.completed::before,
.route-item.current::before {
background: #3f72ff;
opacity: 0.85;
}
.route-body b {
display: block;
font-size: 1.02rem;
color: #ebf2ff;
text-transform: uppercase;
letter-spacing: 0.01em;
}
.route-body p {
margin: 0.25rem 0 0;
color: #9fb0c5;
line-height: 1.45;
font-size: 0.9rem;
white-space: pre-wrap;
}
.route-time {
margin-top: 0.22rem;
font-size: 0.78rem;
}
.request-modal-list {
max-height: 220px;
overflow: auto;
margin-bottom: 0.75rem;
}
.request-modal-item-meta {
margin-top: 0.18rem;
font-size: 0.78rem;
}
.request-file-actions {
margin-top: 0.45rem;
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
.request-file-link {
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.request-attachments-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
margin-bottom: 0.65rem;
}
.request-upload-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.request-chat-block .request-modal-list {
max-height: 480px;
}
.request-chat-list {
max-height: 520px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 0.28rem;
padding: 0.45rem 0.28rem 0.28rem;
border: 1px solid var(--line);
border-radius: 12px;
background:
radial-gradient(ellipse at top left, rgba(82, 109, 156, 0.16), transparent 45%),
radial-gradient(ellipse at bottom right, rgba(57, 86, 126, 0.12), transparent 45%),
rgba(15, 23, 34, 0.72);
}
.request-chat-list .chat-message {
padding: 0;
margin: 0;
border: none;
background: transparent;
display: flex;
flex-direction: column;
gap: 0.2rem;
max-width: min(79%, 680px);
}
.request-chat-list .chat-message.incoming {
align-self: flex-start;
align-items: flex-start;
}
.request-chat-list .chat-message.outgoing {
align-self: flex-end;
align-items: flex-end;
}
.chat-message-author {
font-size: 0.72rem;
color: #9db3cf;
line-height: 1.3;
padding-inline: 0.3rem;
}
.chat-message-bubble {
border: 1px solid #44556f;
border-radius: 15px 15px 15px 6px;
background: linear-gradient(165deg, rgba(39, 52, 69, 0.94), rgba(24, 35, 49, 0.98));
padding: 0.5rem 0.62rem 0.44rem;
min-width: 120px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.16);
}
.chat-message.outgoing .chat-message-bubble {
border-color: #5f86d1;
border-radius: 15px 15px 6px 15px;
background: linear-gradient(165deg, rgba(63, 98, 169, 0.94), rgba(44, 73, 130, 0.98));
}
.chat-message-text {
margin: 0;
font-size: 0.9rem;
line-height: 1.43;
color: #e6eef9;
white-space: pre-wrap;
word-break: break-word;
}
.chat-message-time {
margin-top: 0.32rem;
font-size: 0.74rem;
color: #aab9cc;
text-align: right;
}
.chat-date-divider {
margin: 0.32rem 0 0.24rem;
padding: 0;
border: none;
background: transparent;
display: flex;
justify-content: center;
}
.chat-date-divider span {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.16rem 0.56rem;
border-radius: 999px;
border: 1px solid rgba(131, 151, 178, 0.34);
background: rgba(46, 61, 84, 0.5);
color: #b8c9df;
font-size: 0.72rem;
letter-spacing: 0.02em;
line-height: 1.2;
}
.request-preview-modal {
width: min(980px, 100%);
}
.request-preview-body {
width: 100%;
min-height: 280px;
max-height: calc(92vh - 90px);
overflow: auto;
border: 1px solid var(--line);
border-radius: 12px;
background: #0f1722;
display: grid;
place-items: center;
gap: 0.7rem;
padding: 0.45rem;
}
.request-preview-frame {
width: 100%;
height: min(72vh, 760px);
border: none;
}
.request-preview-image {
max-width: 100%;
max-height: 72vh;
object-fit: contain;
}
.request-preview-video {
width: min(100%, 860px);
max-height: 72vh;
}
.request-preview-note {
color: var(--muted);
text-align: center;
margin: 0;
}
.request-preview-download {
text-decoration: none;
}
.overlay { .overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@ -688,6 +1078,9 @@
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.triple { grid-template-columns: 1fr; } .triple { grid-template-columns: 1fr; }
.config-layout { grid-template-columns: 1fr; } .config-layout { grid-template-columns: 1fr; }
.request-modal-grid { grid-template-columns: 1fr; }
.request-workspace-layout { grid-template-columns: 1fr; }
.request-card-grid { grid-template-columns: 1fr; }
} }
@media (max-width: 920px) { @media (max-width: 920px) {

View file

@ -4,12 +4,12 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Административная панель • Правовой трекер</title> <title>Административная панель • Правовой трекер</title>
<link rel="stylesheet" href="/admin.css" integrity="sha384-ob5ClyWT89HFMlY1xFaLvCa0+FaL5KHhc//V2owTg+iFay2Lx0Y2U7fuGnRozMzD" crossorigin="anonymous"> <link rel="stylesheet" href="/admin.css?v=20260225-1">
</head> </head>
<body> <body>
<div id="admin-root"></div> <div id="admin-root"></div>
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script> <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script> <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
<script src="/admin.js"></script> <script src="/admin.js?v=20260225-1"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load diff

397
app/web/client.css Normal file
View file

@ -0,0 +1,397 @@
:root {
--bg: #0d1217;
--surface: #171f29;
--surface-2: #1f2a37;
--text: #f4f7fb;
--muted: #a8b2c2;
--accent: #d4a968;
--line: rgba(207, 217, 231, 0.18);
--ok: #49b68e;
--danger: #ff7b7b;
--radius: 18px;
--shadow: 0 30px 70px rgba(0, 0, 0, 0.32);
--maxw: 1180px;
}
* { box-sizing: border-box; }
html,
body {
margin: 0;
padding: 0;
background: radial-gradient(circle at 12% 0%, #1a2430 0, var(--bg) 48%), var(--bg);
color: var(--text);
font-family: "Manrope", sans-serif;
}
.wrap {
width: min(var(--maxw), calc(100% - 1.5rem));
margin: 0 auto;
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
backdrop-filter: blur(10px);
background: rgba(13, 18, 23, 0.78);
border-bottom: 1px solid var(--line);
}
.topbar-inner {
min-height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
font-size: 0.84rem;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 800;
color: #eef4ff;
}
.nav a {
text-decoration: none;
color: #d6deea;
font-size: 0.93rem;
font-weight: 600;
}
.client-shell {
padding: 2rem 0 2.5rem;
}
.section-head {
margin-bottom: 1rem;
}
h1 {
margin: 0;
font-family: "Prata", serif;
font-size: clamp(1.75rem, 4vw, 2.7rem);
}
h2 {
margin: 0 0 0.65rem;
font-size: 1.03rem;
}
.subtitle {
margin: 0.65rem 0 0;
color: var(--muted);
line-height: 1.6;
}
.cabinet-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
margin-top: 0.9rem;
}
.cabinet-card {
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(23, 32, 42, 0.9), rgba(17, 24, 33, 0.95));
padding: 1rem;
box-shadow: var(--shadow);
}
.request-switcher {
display: flex;
gap: 0.6rem;
align-items: end;
}
.field {
display: flex;
flex-direction: column;
gap: 0.34rem;
}
.field.grow {
flex: 1;
}
label {
font-size: 0.76rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9fb0c6;
font-weight: 700;
}
input,
textarea,
select {
width: 100%;
border-radius: 12px;
border: 1px solid #3b4b5f;
background: rgba(255, 255, 255, 0.03);
color: #ecf2fb;
font: inherit;
font-size: 16px;
padding: 0.72rem 0.8rem;
}
textarea {
min-height: 84px;
resize: vertical;
}
.btn {
border: 1px solid transparent;
border-radius: 999px;
padding: 0.82rem 1.25rem;
font-family: inherit;
font-size: 0.93rem;
font-weight: 700;
cursor: pointer;
}
.btn-ghost {
border-color: var(--line);
color: #dde6f2;
background: rgba(255, 255, 255, 0.04);
}
.cabinet-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 0.7rem;
}
.meta-row {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.58rem 0.65rem;
background: rgba(255, 255, 255, 0.02);
}
.meta-row small {
display: block;
color: #9fb0c6;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.2rem;
}
.meta-row b {
display: block;
color: #eaf2ff;
font-size: 0.9rem;
font-weight: 700;
line-height: 1.4;
}
.simple-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.5rem;
max-height: 280px;
overflow: auto;
}
.simple-item {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.58rem 0.65rem;
background: rgba(255, 255, 255, 0.02);
}
.simple-item p {
margin: 0.24rem 0 0;
color: #d8e3f3;
line-height: 1.5;
font-size: 0.92rem;
overflow-wrap: anywhere;
}
.simple-item time {
color: #9eb1ca;
font-size: 0.78rem;
}
.file-actions {
margin-top: 0.45rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.file-link-btn {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: #f6d7a8;
text-decoration: none;
font-size: 0.82rem;
padding: 0.32rem 0.68rem;
font: inherit;
}
.file-link-btn:hover {
border-color: rgba(212, 168, 106, 0.45);
background: rgba(212, 168, 106, 0.14);
}
.preview-body .file-link-btn {
margin: 0.7rem;
}
.preview-overlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(6, 10, 14, 0.82);
backdrop-filter: blur(4px);
z-index: 60;
}
.preview-overlay.open {
display: flex;
}
.preview-modal {
width: min(980px, 100%);
max-height: 92vh;
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(21, 31, 42, 0.96), rgba(14, 21, 28, 0.98));
box-shadow: var(--shadow);
padding: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.preview-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
}
.preview-head h3 {
margin: 0;
font-family: "Prata", serif;
font-size: 1.15rem;
}
.close-btn {
border: 1px solid var(--line);
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.04);
color: #d7e4f5;
cursor: pointer;
font-size: 1.05rem;
}
.preview-body {
width: 100%;
min-height: 280px;
max-height: calc(92vh - 76px);
overflow: auto;
border: 1px solid var(--line);
border-radius: 12px;
background: #0f1722;
display: grid;
place-items: center;
}
.preview-frame {
width: 100%;
height: min(72vh, 760px);
border: none;
}
.preview-image {
max-width: 100%;
max-height: 72vh;
object-fit: contain;
}
.preview-video {
width: min(100%, 860px);
max-height: 72vh;
}
.preview-note {
padding: 0.9rem;
color: var(--muted);
text-align: center;
}
.chat-form {
margin-top: 0.7rem;
display: grid;
gap: 0.55rem;
}
.file-row {
margin-top: 0.7rem;
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.status {
margin: 0.7rem 0 0;
color: #9bafc8;
font-size: 0.9rem;
min-height: 1.2rem;
}
.status.ok { color: var(--ok); }
.status.error { color: var(--danger); }
@media (max-width: 860px) {
.cabinet-layout {
grid-template-columns: 1fr;
}
.cabinet-meta {
grid-template-columns: 1fr;
}
.request-switcher {
flex-direction: column;
align-items: stretch;
}
}
@media (max-width: 520px) {
.wrap {
width: calc(100% - 1rem);
}
.topbar {
position: static;
}
.cabinet-card {
padding: 0.85rem;
}
.file-row {
flex-direction: column;
align-items: stretch;
}
}

104
app/web/client.html Normal file
View file

@ -0,0 +1,104 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Страница клиента • Правовой трекер</title>
<link rel="stylesheet" href="/client.css">
</head>
<body>
<header class="topbar">
<div class="wrap topbar-inner">
<div class="brand">Кабинет клиента</div>
<nav class="nav">
<a href="/">На лендинг</a>
</nav>
</div>
</header>
<main class="wrap">
<section class="client-shell">
<div class="section-head">
<div>
<h1>Работа с заявками</h1>
<p class="subtitle">Выберите заявку, следите за статусом, перепиской, файлами и счетами.</p>
</div>
</div>
<article class="cabinet-card">
<h2>Мои заявки</h2>
<div class="request-switcher">
<div class="field grow">
<label for="client-request-select">Номер заявки</label>
<select id="client-request-select"></select>
</div>
<button class="btn btn-ghost" id="client-refresh" type="button">Обновить</button>
</div>
<p class="status" id="client-page-status"></p>
<div id="cabinet-summary" hidden>
<div class="cabinet-meta">
<div class="meta-row">
<small>Статус</small>
<b id="cabinet-request-status">-</b>
</div>
<div class="meta-row">
<small>Тема</small>
<b id="cabinet-request-topic">-</b>
</div>
<div class="meta-row">
<small>Создана</small>
<b id="cabinet-request-created">-</b>
</div>
<div class="meta-row">
<small>Обновлена</small>
<b id="cabinet-request-updated">-</b>
</div>
</div>
</div>
</article>
<div class="cabinet-layout">
<article class="cabinet-card">
<h2>Чат с юристом</h2>
<ul class="simple-list" id="cabinet-messages"></ul>
<form class="chat-form" id="cabinet-chat-form">
<textarea id="cabinet-chat-body" placeholder="Введите сообщение" disabled></textarea>
<button class="btn btn-ghost" type="submit" id="cabinet-chat-send" disabled>Отправить сообщение</button>
</form>
</article>
<article class="cabinet-card">
<h2>Файлы по заявке</h2>
<ul class="simple-list" id="cabinet-files"></ul>
<div class="file-row">
<input id="cabinet-file-input" type="file" disabled>
<button class="btn btn-ghost" id="cabinet-file-upload" type="button" disabled>Загрузить файл</button>
</div>
</article>
<article class="cabinet-card">
<h2>Счета и оплата</h2>
<ul class="simple-list" id="cabinet-invoices"></ul>
</article>
<article class="cabinet-card">
<h2>История изменений</h2>
<ul class="simple-list" id="cabinet-timeline"></ul>
</article>
</div>
</section>
</main>
<div class="preview-overlay" id="file-preview-overlay" aria-hidden="true">
<div class="preview-modal" role="dialog" aria-modal="true" aria-labelledby="file-preview-title">
<div class="preview-head">
<h3 id="file-preview-title">Предпросмотр файла</h3>
<button class="close-btn" id="file-preview-close" type="button" aria-label="Закрыть">×</button>
</div>
<div class="preview-body" id="file-preview-body"></div>
</div>
</div>
<script src="/client.js"></script>
</body>
</html>

502
app/web/client.js Normal file
View file

@ -0,0 +1,502 @@
(function () {
const requestSelect = document.getElementById("client-request-select");
const refreshButton = document.getElementById("client-refresh");
const pageStatus = document.getElementById("client-page-status");
const cabinetSummary = document.getElementById("cabinet-summary");
const cabinetRequestStatus = document.getElementById("cabinet-request-status");
const cabinetRequestTopic = document.getElementById("cabinet-request-topic");
const cabinetRequestCreated = document.getElementById("cabinet-request-created");
const cabinetRequestUpdated = document.getElementById("cabinet-request-updated");
const cabinetMessages = document.getElementById("cabinet-messages");
const cabinetFiles = document.getElementById("cabinet-files");
const cabinetInvoices = document.getElementById("cabinet-invoices");
const cabinetTimeline = document.getElementById("cabinet-timeline");
const cabinetChatForm = document.getElementById("cabinet-chat-form");
const cabinetChatBody = document.getElementById("cabinet-chat-body");
const cabinetChatSend = document.getElementById("cabinet-chat-send");
const cabinetFileInput = document.getElementById("cabinet-file-input");
const cabinetFileUpload = document.getElementById("cabinet-file-upload");
const previewOverlay = document.getElementById("file-preview-overlay");
const previewTitle = document.getElementById("file-preview-title");
const previewClose = document.getElementById("file-preview-close");
const previewBody = document.getElementById("file-preview-body");
let activeTrack = "";
let activeRequestId = "";
function formatDate(value) {
if (!value) return "-";
try {
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) return value;
return dt.toLocaleString("ru-RU");
} catch (_) {
return value;
}
}
function setStatus(el, message, kind) {
el.className = "status";
if (kind === "ok") el.classList.add("ok");
if (kind === "error") el.classList.add("error");
el.textContent = message;
}
async function parseJsonSafe(response) {
try {
return await response.json();
} catch (_) {
return null;
}
}
function apiErrorDetail(data, fallbackMessage) {
if (data && typeof data.detail === "string" && data.detail.trim()) return data.detail;
return fallbackMessage;
}
function setCabinetEnabled(enabled) {
cabinetChatBody.disabled = !enabled;
cabinetChatSend.disabled = !enabled;
cabinetFileInput.disabled = !enabled;
cabinetFileUpload.disabled = !enabled;
requestSelect.disabled = !enabled;
}
function clearList(node, emptyMessage) {
node.innerHTML = "";
const li = document.createElement("li");
li.className = "simple-item";
const p = document.createElement("p");
p.textContent = emptyMessage;
li.appendChild(p);
node.appendChild(li);
}
function detectPreviewKind(fileName, mimeType) {
const name = String(fileName || "").toLowerCase();
const mime = String(mimeType || "").toLowerCase();
if (mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name)) return "image";
if (mime.startsWith("video/") || /\.(mp4|webm|ogg|mov|m4v)$/.test(name)) return "video";
if (mime === "application/pdf" || /\.pdf$/.test(name)) return "pdf";
return "none";
}
function closePreview() {
if (!previewOverlay || !previewBody) return;
previewOverlay.classList.remove("open");
previewOverlay.setAttribute("aria-hidden", "true");
previewBody.innerHTML = "";
}
function openPreview(item) {
if (!previewOverlay || !previewBody || !previewTitle || !item?.download_url) return;
previewBody.innerHTML = "";
previewTitle.textContent = item.file_name || "Предпросмотр файла";
const kind = detectPreviewKind(item.file_name, item.mime_type);
if (kind === "image") {
const img = document.createElement("img");
img.className = "preview-image";
img.src = item.download_url;
img.alt = item.file_name || "Изображение";
previewBody.appendChild(img);
} else if (kind === "video") {
const video = document.createElement("video");
video.className = "preview-video";
video.src = item.download_url;
video.controls = true;
video.preload = "metadata";
previewBody.appendChild(video);
} else if (kind === "pdf") {
const frame = document.createElement("iframe");
frame.className = "preview-frame";
frame.src = item.download_url;
frame.title = item.file_name || "PDF";
previewBody.appendChild(frame);
} else {
const note = document.createElement("p");
note.className = "preview-note";
note.textContent = "Для этого типа файла доступно только открытие или скачивание.";
previewBody.appendChild(note);
}
const openLink = document.createElement("a");
openLink.className = "file-link-btn";
openLink.href = item.download_url;
openLink.textContent = "Открыть / скачать";
openLink.target = "_blank";
openLink.rel = "noopener noreferrer";
previewBody.appendChild(openLink);
previewOverlay.classList.add("open");
previewOverlay.setAttribute("aria-hidden", "false");
}
function renderMessages(items) {
cabinetMessages.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetMessages, "Сообщений пока нет.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
const author = item.author_name || item.author_type || "Участник";
p.textContent = author + ": " + (item.body || "");
li.appendChild(p);
cabinetMessages.appendChild(li);
});
}
function renderFiles(items) {
cabinetFiles.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetFiles, "Файлы пока не загружены.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
const sizeKb = Math.max(1, Math.round(Number(item.size_bytes || 0) / 1024));
p.textContent = item.file_name + " (" + sizeKb + " КБ)";
li.appendChild(p);
const actions = document.createElement("div");
actions.className = "file-actions";
if (detectPreviewKind(item.file_name, item.mime_type) !== "none") {
const previewBtn = document.createElement("button");
previewBtn.type = "button";
previewBtn.className = "file-link-btn";
previewBtn.textContent = "Предпросмотр";
previewBtn.addEventListener("click", () => openPreview(item));
actions.appendChild(previewBtn);
}
const link = document.createElement("a");
link.className = "file-link-btn";
link.href = item.download_url;
link.textContent = "Открыть / скачать";
link.target = "_blank";
link.rel = "noopener noreferrer";
actions.appendChild(link);
li.appendChild(actions);
cabinetFiles.appendChild(li);
});
}
function renderInvoices(items) {
cabinetInvoices.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetInvoices, "Счета пока не выставлены.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = "Сформирован: " + formatDate(item.issued_at);
li.appendChild(time);
const p = document.createElement("p");
const amount = Number(item.amount || 0).toLocaleString("ru-RU");
p.textContent =
(item.invoice_number || "Счет") +
" • " +
(item.status_label || item.status || "-") +
" • " +
amount +
" " +
(item.currency || "RUB");
li.appendChild(p);
const link = document.createElement("a");
link.href = item.download_url;
link.textContent = "Открыть / скачать PDF";
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.color = "#f6d7a8";
li.appendChild(link);
cabinetInvoices.appendChild(li);
});
}
function renderTimeline(items) {
cabinetTimeline.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetTimeline, "История пока пуста.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
if (item.type === "status_change") {
p.textContent = "Статус: " + (item.payload?.from_status || "NEW") + " -> " + (item.payload?.to_status || "-");
} else if (item.type === "message") {
const author = item.payload?.author_name || item.payload?.author_type || "Участник";
p.textContent = "Сообщение от " + author + ": " + (item.payload?.body || "");
} else if (item.type === "attachment") {
p.textContent = "Файл: " + (item.payload?.file_name || "вложение");
} else {
p.textContent = "Событие";
}
li.appendChild(p);
cabinetTimeline.appendChild(li);
});
}
async function fetchRequestByTrack(trackNumber) {
const response = await fetch("/api/public/requests/" + encodeURIComponent(trackNumber));
const data = await parseJsonSafe(response);
return { response, data };
}
async function refreshCabinetData() {
if (!activeTrack) return;
const [messagesRes, filesRes, invoicesRes, timelineRes] = await Promise.all([
fetch("/api/public/chat/requests/" + encodeURIComponent(activeTrack) + "/messages"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/attachments"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/invoices"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline"),
]);
const messagesData = await parseJsonSafe(messagesRes);
const filesData = await parseJsonSafe(filesRes);
const invoicesData = await parseJsonSafe(invoicesRes);
const timelineData = await parseJsonSafe(timelineRes);
if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения"));
if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы"));
if (!invoicesRes.ok) throw new Error(apiErrorDetail(invoicesData, "Не удалось загрузить счета"));
if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю"));
renderMessages(messagesData);
renderFiles(filesData);
renderInvoices(invoicesData);
renderTimeline(timelineData);
}
function syncRequestSelector(rows, selectedTrack) {
requestSelect.innerHTML = "";
rows.forEach((row) => {
const option = document.createElement("option");
option.value = String(row.track_number || "");
option.textContent = String(row.track_number || "Без номера") + " • " + String(row.status_code || "-");
requestSelect.appendChild(option);
});
if (selectedTrack) requestSelect.value = selectedTrack;
}
async function openCabinetByTrack(trackNumber) {
if (!trackNumber) return;
try {
setStatus(pageStatus, "Открываем заявку...", null);
const { response, data } = await fetchRequestByTrack(trackNumber);
if (response.status === 401 || response.status === 403) {
window.location.href = "/";
return;
}
if (!response.ok) {
throw new Error(apiErrorDetail(data, "Не удалось открыть заявку"));
}
activeTrack = trackNumber;
activeRequestId = data.id;
cabinetRequestStatus.textContent = data.status_code || "-";
cabinetRequestTopic.textContent = data.topic_code || "Не указана";
cabinetRequestCreated.textContent = formatDate(data.created_at);
cabinetRequestUpdated.textContent = formatDate(data.updated_at);
cabinetSummary.hidden = false;
setCabinetEnabled(true);
await refreshCabinetData();
setStatus(pageStatus, "Открыта заявка: " + trackNumber, "ok");
} catch (error) {
setStatus(pageStatus, error?.message || "Не удалось открыть заявку", "error");
}
}
async function loadMyRequests(preferredTrack) {
const response = await fetch("/api/public/requests/my");
const data = await parseJsonSafe(response);
if (response.status === 401 || response.status === 403) {
window.location.href = "/";
return;
}
if (!response.ok) {
throw new Error(apiErrorDetail(data, "Не удалось загрузить список заявок"));
}
const rows = Array.isArray(data?.rows) ? data.rows : [];
if (!rows.length) {
requestSelect.innerHTML = "";
cabinetSummary.hidden = true;
setCabinetEnabled(false);
setStatus(pageStatus, "По вашему номеру пока нет заявок.", null);
clearList(cabinetMessages, "Сообщений пока нет.");
clearList(cabinetFiles, "Файлы пока не загружены.");
clearList(cabinetInvoices, "Счета пока не выставлены.");
clearList(cabinetTimeline, "История пока пуста.");
return;
}
const tracks = rows.map((row) => String(row.track_number || "")).filter(Boolean);
const selectedTrack = tracks.includes(preferredTrack) ? preferredTrack : tracks[0];
syncRequestSelector(rows, selectedTrack);
await openCabinetByTrack(selectedTrack);
}
requestSelect.addEventListener("change", async () => {
const track = String(requestSelect.value || "").trim();
if (!track) return;
await openCabinetByTrack(track);
});
refreshButton.addEventListener("click", async () => {
try {
await loadMyRequests(activeTrack || String(requestSelect.value || "").trim());
} catch (error) {
setStatus(pageStatus, error?.message || "Не удалось обновить список", "error");
}
});
if (previewClose) {
previewClose.addEventListener("click", closePreview);
}
if (previewOverlay) {
previewOverlay.addEventListener("click", (event) => {
if (event.target === previewOverlay) closePreview();
});
}
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && previewOverlay?.classList.contains("open")) {
closePreview();
}
});
cabinetChatForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!activeTrack) {
setStatus(pageStatus, "Сначала выберите заявку.", "error");
return;
}
const body = String(cabinetChatBody.value || "").trim();
if (!body) return;
try {
setStatus(pageStatus, "Отправляем сообщение...", null);
const response = await fetch("/api/public/chat/requests/" + encodeURIComponent(activeTrack) + "/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body }),
});
const data = await parseJsonSafe(response);
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить сообщение"));
cabinetChatBody.value = "";
await refreshCabinetData();
setStatus(pageStatus, "Сообщение отправлено.", "ok");
} catch (error) {
setStatus(pageStatus, error?.message || "Ошибка отправки сообщения", "error");
}
});
cabinetFileUpload.addEventListener("click", async () => {
if (!activeTrack || !activeRequestId) {
setStatus(pageStatus, "Сначала выберите заявку.", "error");
return;
}
const file = cabinetFileInput.files && cabinetFileInput.files[0];
if (!file) {
setStatus(pageStatus, "Выберите файл для загрузки.", "error");
return;
}
try {
setStatus(pageStatus, "Подготавливаем загрузку файла...", null);
const initResponse = await fetch("/api/public/uploads/init", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_name: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: activeRequestId,
}),
});
const initData = await parseJsonSafe(initResponse);
if (!initResponse.ok) throw new Error(apiErrorDetail(initData, "Не удалось начать загрузку"));
const putResponse = await fetch(initData.presigned_url, {
method: "PUT",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file,
});
if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище");
const completeResponse = await fetch("/api/public/uploads/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: initData.key,
file_name: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: activeRequestId,
}),
});
const completeData = await parseJsonSafe(completeResponse);
if (!completeResponse.ok) throw new Error(apiErrorDetail(completeData, "Не удалось завершить загрузку"));
cabinetFileInput.value = "";
await refreshCabinetData();
setStatus(pageStatus, "Файл загружен.", "ok");
} catch (error) {
setStatus(pageStatus, error?.message || "Ошибка загрузки файла", "error");
}
});
(async function bootstrap() {
const params = new URLSearchParams(window.location.search);
const preferredTrack = String(params.get("track") || "").trim().toUpperCase();
setCabinetEnabled(false);
clearList(cabinetMessages, "Сообщений пока нет.");
clearList(cabinetFiles, "Файлы пока не загружены.");
clearList(cabinetInvoices, "Счета пока не выставлены.");
clearList(cabinetTimeline, "История пока пуста.");
try {
await loadMyRequests(preferredTrack);
} catch (error) {
setStatus(pageStatus, error?.message || "Не удалось открыть страницу клиента", "error");
}
})();
})();

View file

@ -158,6 +158,8 @@
padding: 1.3rem; padding: 1.3rem;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
display: flex;
flex-direction: column;
} }
.panel::before { .panel::before {
@ -215,6 +217,21 @@
line-height: 1.35; line-height: 1.35;
} }
.consultation-quote {
margin-top: auto;
padding-top: 0.78rem;
border-top: 1px dashed rgba(207, 217, 231, 0.24);
opacity: 0.92;
}
.consultation-quote p {
margin: 0;
font-size: 0.92rem;
line-height: 1.5;
color: #c6d4e8;
min-height: 2.8rem;
}
section { padding: 1.3rem 0 2.2rem; } section { padding: 1.3rem 0 2.2rem; }
.section-head { .section-head {
@ -313,14 +330,25 @@
.timeline h3 { margin: 0 0 0.35rem; font-size: 1rem; } .timeline h3 { margin: 0 0 0.35rem; font-size: 1rem; }
.timeline p { margin: 0; color: var(--muted); line-height: 1.55; } .timeline p { margin: 0; color: var(--muted); line-height: 1.55; }
.quote { .approach-note {
border: 1px solid #4b5b71; border: 1px solid #4b5b71;
border-radius: 16px; border-radius: 16px;
background: linear-gradient(160deg, #1e2b3c, #1a2432); background: linear-gradient(160deg, #1e2b3c, #1a2432);
padding: 1rem; padding: 1rem;
align-self: start;
} }
.quote p { .approach-note small {
display: block;
margin-bottom: 0.52rem;
font-size: 0.74rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #9cb2cb;
font-weight: 700;
}
.approach-note p {
margin: 0; margin: 0;
min-height: 5.3rem; min-height: 5.3rem;
line-height: 1.6; line-height: 1.6;
@ -478,7 +506,7 @@
font-weight: 700; font-weight: 700;
} }
input, textarea { input, textarea, select {
width: 100%; width: 100%;
border-radius: 12px; border-radius: 12px;
border: 1px solid #3b4b5f; border: 1px solid #3b4b5f;
@ -494,6 +522,15 @@
resize: vertical; resize: vertical;
} }
select {
appearance: none;
background-image: linear-gradient(45deg, transparent 50%, #cfd9e7 50%), linear-gradient(135deg, #cfd9e7 50%, transparent 50%);
background-position: calc(100% - 18px) calc(1em + 2px), calc(100% - 12px) calc(1em + 2px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
padding-right: 2rem;
}
.form-foot { .form-foot {
margin-top: 0.9rem; margin-top: 0.9rem;
display: flex; display: flex;

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Аудиторы корпоративной безопасности</title> <title>Аудиторы корпоративной безопасности</title>
<meta name="description" content="Юридический консалтинг и судебное сопровождение для сложных бизнес-ситуаций."> <meta name="description" content="Юридический консалтинг и судебное сопровождение для сложных бизнес-ситуаций.">
<link rel="stylesheet" href="/landing.css" integrity="sha384-f2MyL8409LTp2ap3kS1Yf2FMNVyeypb/qY1jl7WtZpImICXE/fpZCqZZT6keMp50" crossorigin="anonymous"> <link rel="stylesheet" href="/landing.css">
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@ -15,8 +15,7 @@
<a href="#practices">Компетенции</a> <a href="#practices">Компетенции</a>
<a href="#approach">Подход</a> <a href="#approach">Подход</a>
<a href="#expert">Эксперт</a> <a href="#expert">Эксперт</a>
<a href="#cabinet">Кабинет клиента</a> <button class="btn btn-ghost" type="button" data-open-access>Мои заявки</button>
<a href="/admin" class="btn btn-ghost">Админ-панель</a>
<button class="btn btn-ghost" type="button" data-open-modal>Оставить заявку</button> <button class="btn btn-ghost" type="button" data-open-modal>Оставить заявку</button>
</nav> </nav>
</div> </div>
@ -32,6 +31,7 @@
</p> </p>
<div class="hero-actions"> <div class="hero-actions">
<button class="btn btn-primary" type="button" data-open-modal>Записаться на консультацию</button> <button class="btn btn-primary" type="button" data-open-modal>Записаться на консультацию</button>
<button class="btn btn-ghost" type="button" data-open-access>Работа с заявкой</button>
<a class="btn btn-ghost" href="#practices">Смотреть практики</a> <a class="btn btn-ghost" href="#practices">Смотреть практики</a>
</div> </div>
</div> </div>
@ -53,6 +53,10 @@
<span>объем восстановленных прав</span> <span>объем восстановленных прав</span>
</div> </div>
</div> </div>
<div class="consultation-quote" aria-live="polite">
<p id="quote-text">Загрузка данных...</p>
<div class="quote-meta" id="quote-meta"></div>
</div>
</aside> </aside>
</section> </section>
@ -113,10 +117,9 @@
<p>Сопровождаем исполнение решения, фиксируем сроки и контрольные точки, отчитываемся в понятном бизнес-формате.</p> <p>Сопровождаем исполнение решения, фиксируем сроки и контрольные точки, отчитываемся в понятном бизнес-формате.</p>
</div> </div>
</article> </article>
<article class="quote"> <article class="approach-note">
<small>Публичные цитаты</small> <small>Принцип работы</small>
<p id="quote-text">Загрузка данных...</p> <p>Каждую заявку ведем как проект: фиксируем цель, измеряем прогресс и заранее обозначаем ограничения по срокам и рискам.</p>
<div class="quote-meta" id="quote-meta"></div>
</article> </article>
</div> </div>
</section> </section>
@ -144,81 +147,8 @@
</article> </article>
</section> </section>
<section id="cabinet">
<div class="section-head">
<div>
<h2>Кабинет клиента</h2>
<p class="subtitle">Введите номер заявки, подтвердите доступ по OTP и отслеживайте статус, переписку и файлы в одном окне.</p>
</div>
</div>
<div class="cabinet-layout">
<article class="cabinet-card">
<h3>Доступ по номеру заявки</h3>
<div class="field">
<label for="cabinet-track">Номер заявки</label>
<input id="cabinet-track" type="text" placeholder="TRK-XXXXXXXXXX">
</div>
<div class="form-foot">
<button class="btn btn-primary" id="cabinet-open" type="button">Открыть кабинет</button>
<p class="status" id="cabinet-status"></p>
</div>
<div id="cabinet-summary" hidden>
<div class="cabinet-meta">
<div class="meta-row">
<small>Статус</small>
<b id="cabinet-request-status">-</b>
</div>
<div class="meta-row">
<small>Тема</small>
<b id="cabinet-request-topic">-</b>
</div>
<div class="meta-row">
<small>Создана</small>
<b id="cabinet-request-created">-</b>
</div>
<div class="meta-row">
<small>Обновлена</small>
<b id="cabinet-request-updated">-</b>
</div>
</div>
</div>
</article>
<article class="cabinet-card">
<h3>Чат с юристом</h3>
<ul class="simple-list" id="cabinet-messages"></ul>
<form class="chat-form" id="cabinet-chat-form">
<textarea id="cabinet-chat-body" placeholder="Введите сообщение" disabled></textarea>
<button class="btn btn-ghost" type="submit" id="cabinet-chat-send" disabled>Отправить сообщение</button>
</form>
</article>
<article class="cabinet-card">
<h3>Файлы по заявке</h3>
<ul class="simple-list" id="cabinet-files"></ul>
<div class="file-row">
<input id="cabinet-file-input" type="file" disabled>
<button class="btn btn-ghost" id="cabinet-file-upload" type="button" disabled>Загрузить файл</button>
</div>
</article>
<article class="cabinet-card">
<h3>Счета и оплата</h3>
<ul class="simple-list" id="cabinet-invoices"></ul>
</article>
<article class="cabinet-card">
<h3>История изменений</h3>
<ul class="simple-list" id="cabinet-timeline"></ul>
</article>
</div>
</section>
<section class="cta-band"> <section class="cta-band">
<p> <p>Создайте заявку и получите номер обращения. По нему вы сможете отслеживать статус, чат и документы в отдельной странице клиента.</p>
Если вы пришли на сайт по рекомендации, укажите имя рекомендателя при отправке заявки.
Это поможет быстрее подготовиться к консультации и учесть контекст вашей ситуации.
</p>
<button class="btn btn-primary" type="button" data-open-modal>Создать заявку</button> <button class="btn btn-primary" type="button" data-open-modal>Создать заявку</button>
</section> </section>
</main> </main>
@ -246,12 +176,14 @@
<input id="phone" name="phone" type="tel" required placeholder="+7 (900) 000-00-00"> <input id="phone" name="phone" type="tel" required placeholder="+7 (900) 000-00-00">
</div> </div>
<div class="field full"> <div class="field full">
<label for="description">Описание задачи</label> <label for="topic">Тема обращения</label>
<textarea id="description" name="description" placeholder="Кратко опишите ситуацию"></textarea> <select id="topic" name="topic" required>
<option value="">Выберите тему</option>
</select>
</div> </div>
<div class="field full"> <div class="field full">
<label for="referral">Кто вас порекомендовал</label> <label for="description">Описание задачи</label>
<input id="referral" name="referral" type="text" placeholder="Имя рекомендателя"> <textarea id="description" name="description" placeholder="Кратко опишите ситуацию"></textarea>
</div> </div>
<div class="form-foot field full"> <div class="form-foot field full">
<button class="btn btn-primary" type="submit">Отправить заявку</button> <button class="btn btn-primary" type="submit">Отправить заявку</button>
@ -261,6 +193,33 @@
</div> </div>
</div> </div>
<script src="/landing.js" integrity="sha384-MfqV2MU/1TCgCqbg3o7JA2fTo0ZGtICVl5jvDOtDtS52TkjRf3kyrXnPpU8zaSPm" crossorigin="anonymous"></script> <div class="modal-backdrop" id="access-modal" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="access-title">
<div class="modal-head">
<div>
<h3 id="access-title">Вход в страницу заявок</h3>
<p>Введите номер телефона, получите OTP и перейдите к своим заявкам.</p>
</div>
<button class="close" type="button" data-close-access aria-label="Закрыть">×</button>
</div>
<form id="access-form" class="form">
<div class="field full">
<label for="access-phone">Телефон</label>
<input id="access-phone" name="access-phone" type="tel" required placeholder="+7 (900) 000-00-00">
</div>
<div class="field full">
<label for="access-code">Одноразовый пароль (OTP)</label>
<input id="access-code" name="access-code" type="text" inputmode="numeric" pattern="[0-9]{4,8}" placeholder="Введите код из SMS">
</div>
<div class="form-foot field full">
<button class="btn btn-ghost" type="button" id="access-send-otp">Получить одноразовый пароль</button>
<button class="btn btn-primary" type="submit">Перейти</button>
<p class="status" id="access-status"></p>
</div>
</form>
</div>
</div>
<script src="/landing.js"></script>
</body> </body>
</html> </html>

View file

@ -1,503 +1,257 @@
(function () { (function () {
const modal = document.getElementById("request-modal"); const requestModal = document.getElementById("request-modal");
const openButtons = document.querySelectorAll("[data-open-modal]"); const accessModal = document.getElementById("access-modal");
const closeButtons = document.querySelectorAll("[data-close-modal]"); const requestOpenButtons = document.querySelectorAll("[data-open-modal]");
const form = document.getElementById("request-form"); const requestCloseButtons = document.querySelectorAll("[data-close-modal]");
const status = document.getElementById("form-status"); const accessOpenButtons = document.querySelectorAll("[data-open-access]");
const quoteText = document.getElementById("quote-text"); const accessCloseButtons = document.querySelectorAll("[data-close-access]");
const quoteMeta = document.getElementById("quote-meta");
const cabinetTrackInput = document.getElementById("cabinet-track");
const cabinetOpenButton = document.getElementById("cabinet-open");
const cabinetStatus = document.getElementById("cabinet-status");
const cabinetSummary = document.getElementById("cabinet-summary");
const cabinetRequestStatus = document.getElementById("cabinet-request-status");
const cabinetRequestTopic = document.getElementById("cabinet-request-topic");
const cabinetRequestCreated = document.getElementById("cabinet-request-created");
const cabinetRequestUpdated = document.getElementById("cabinet-request-updated");
const cabinetMessages = document.getElementById("cabinet-messages");
const cabinetFiles = document.getElementById("cabinet-files");
const cabinetInvoices = document.getElementById("cabinet-invoices");
const cabinetTimeline = document.getElementById("cabinet-timeline");
const cabinetChatForm = document.getElementById("cabinet-chat-form");
const cabinetChatBody = document.getElementById("cabinet-chat-body");
const cabinetChatSend = document.getElementById("cabinet-chat-send");
const cabinetFileInput = document.getElementById("cabinet-file-input");
const cabinetFileUpload = document.getElementById("cabinet-file-upload");
let activeTrack = ""; const requestForm = document.getElementById("request-form");
let activeRequestId = ""; const requestStatus = document.getElementById("form-status");
const topicSelect = document.getElementById("topic");
function openModal() { const accessForm = document.getElementById("access-form");
modal.classList.add("open"); const accessPhoneInput = document.getElementById("access-phone");
modal.setAttribute("aria-hidden", "false"); const accessCodeInput = document.getElementById("access-code");
document.body.classList.add("modal-open"); const accessSendOtpButton = document.getElementById("access-send-otp");
const accessStatus = document.getElementById("access-status");
const quoteText = document.getElementById("quote-text");
const quoteMeta = document.getElementById("quote-meta");
function setStatus(el, message, kind) {
if (!el) return;
el.className = "status";
if (kind === "ok") el.classList.add("ok");
if (kind === "error") el.classList.add("error");
el.textContent = message;
}
async function parseJsonSafe(response) {
try {
return await response.json();
} catch (_) {
return null;
}
}
function apiErrorDetail(data, fallbackMessage) {
if (data && typeof data.detail === "string" && data.detail.trim()) return data.detail;
return fallbackMessage;
}
function openModal(modal) {
if (!modal) return;
modal.classList.add("open");
modal.setAttribute("aria-hidden", "false");
document.body.classList.add("modal-open");
}
function closeModal(modal) {
if (!modal) return;
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
if (!document.querySelector(".modal-backdrop.open")) {
document.body.classList.remove("modal-open");
}
}
requestOpenButtons.forEach((button) => {
button.addEventListener("click", () => openModal(requestModal));
});
requestCloseButtons.forEach((button) => {
button.addEventListener("click", () => closeModal(requestModal));
});
accessOpenButtons.forEach((button) => {
button.addEventListener("click", async () => {
try {
const response = await fetch("/api/public/requests/my");
if (response.ok) {
window.location.href = "/client.html";
return;
}
} catch (_) {}
setStatus(accessStatus, "", null);
openModal(accessModal);
});
});
accessCloseButtons.forEach((button) => {
button.addEventListener("click", () => closeModal(accessModal));
});
[requestModal, accessModal].forEach((modal) => {
if (!modal) return;
modal.addEventListener("click", (event) => {
if (event.target === modal) closeModal(modal);
});
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
closeModal(requestModal);
closeModal(accessModal);
});
async function loadTopics() {
if (!topicSelect) return;
const fallback = [{ code: "consulting", name: "Консультация" }];
let topics = fallback;
try {
const response = await fetch("/api/public/requests/topics");
const data = await parseJsonSafe(response);
if (response.ok && Array.isArray(data) && data.length > 0) {
topics = data;
} }
} catch (_) {}
function closeModal() { topicSelect.innerHTML = '<option value="">Выберите тему</option>';
modal.classList.remove("open"); topics.forEach((row) => {
modal.setAttribute("aria-hidden", "true"); const option = document.createElement("option");
document.body.classList.remove("modal-open"); option.value = String(row.code || "");
} option.textContent = String(row.name || row.code || "Тема");
topicSelect.appendChild(option);
});
}
openButtons.forEach((button) => button.addEventListener("click", openModal)); async function loadQuotes() {
closeButtons.forEach((button) => button.addEventListener("click", closeModal)); try {
const response = await fetch("/api/public/quotes?limit=8&order=random");
if (!response.ok) throw new Error("quotes fetch failed");
const items = await response.json();
if (!Array.isArray(items) || items.length === 0) throw new Error("quotes empty");
let index = 0;
const render = () => {
const quote = items[index % items.length];
quoteText.textContent = quote.text;
quoteMeta.textContent = [quote.author, quote.source].filter(Boolean).join(" • ");
index += 1;
};
render();
if (items.length > 1) setInterval(render, 5500);
} catch (_) {
quoteText.textContent = "С вами работает дружный коллектив профессионалов. Мы уверены в вашем успехе.";
quoteMeta.textContent = "Команда компании";
}
}
modal.addEventListener("click", (event) => { accessSendOtpButton.addEventListener("click", async () => {
if (event.target === modal) closeModal(); const phone = String(accessPhoneInput.value || "").trim();
if (!phone) {
setStatus(accessStatus, "Введите номер телефона.", "error");
return;
}
try {
setStatus(accessStatus, "Отправляем OTP-код...", null);
const response = await fetch("/api/public/otp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "VIEW_REQUEST",
client_phone: phone,
}),
}); });
const data = await parseJsonSafe(response);
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить OTP"));
setStatus(accessStatus, "Код отправлен. Проверьте SMS.", "ok");
} catch (error) {
setStatus(accessStatus, error?.message || "Не удалось отправить OTP", "error");
}
});
document.addEventListener("keydown", (event) => { accessForm.addEventListener("submit", async (event) => {
if (event.key === "Escape" && modal.classList.contains("open")) closeModal(); event.preventDefault();
const phone = String(accessPhoneInput.value || "").trim();
const code = String(accessCodeInput.value || "").trim();
if (!phone || !code) {
setStatus(accessStatus, "Введите телефон и OTP-код.", "error");
return;
}
try {
setStatus(accessStatus, "Проверяем OTP...", null);
const response = await fetch("/api/public/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "VIEW_REQUEST",
client_phone: phone,
code,
}),
}); });
const data = await parseJsonSafe(response);
if (!response.ok) throw new Error(apiErrorDetail(data, "OTP не подтвержден"));
setStatus(accessStatus, "Доступ подтвержден. Переходим...", "ok");
window.location.href = "/client.html";
} catch (error) {
setStatus(accessStatus, error?.message || "Ошибка проверки OTP", "error");
}
});
function formatDate(value) { requestForm.addEventListener("submit", async (event) => {
if (!value) return "-"; event.preventDefault();
try { setStatus(requestStatus, "Отправляем заявку...", null);
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) return value;
return dt.toLocaleString("ru-RU");
} catch (_) {
return value;
}
}
function setStatus(el, message, kind) { const payload = {
el.className = "status"; client_name: String(document.getElementById("name").value || "").trim(),
if (kind === "ok") el.classList.add("ok"); client_phone: String(document.getElementById("phone").value || "").trim(),
if (kind === "error") el.classList.add("error"); topic_code: String(document.getElementById("topic").value || "").trim(),
el.textContent = message; description: String(document.getElementById("description").value || "").trim(),
} extra_fields: {},
};
async function parseJsonSafe(response) { if (!payload.client_name || !payload.client_phone || !payload.topic_code) {
try { setStatus(requestStatus, "Заполните имя, телефон и тему обращения.", "error");
return await response.json(); return;
} catch (_) { }
return null;
}
}
function apiErrorDetail(data, fallbackMessage) { try {
if (data && typeof data.detail === "string" && data.detail.trim()) return data.detail; setStatus(requestStatus, "Отправляем OTP-код...", null);
return fallbackMessage; const otpSend = await fetch("/api/public/otp/send", {
} method: "POST",
headers: { "Content-Type": "application/json" },
function setCabinetEnabled(enabled) { body: JSON.stringify({
cabinetChatBody.disabled = !enabled; purpose: "CREATE_REQUEST",
cabinetChatSend.disabled = !enabled; client_phone: payload.client_phone,
cabinetFileInput.disabled = !enabled; }),
cabinetFileUpload.disabled = !enabled;
}
function clearList(node, emptyMessage) {
node.innerHTML = "";
const li = document.createElement("li");
li.className = "simple-item";
const p = document.createElement("p");
p.textContent = emptyMessage;
li.appendChild(p);
node.appendChild(li);
}
function renderMessages(items) {
cabinetMessages.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetMessages, "Сообщений пока нет.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
const author = item.author_name || item.author_type || "Участник";
p.textContent = author + ": " + (item.body || "");
li.appendChild(p);
cabinetMessages.appendChild(li);
});
}
function renderFiles(items) {
cabinetFiles.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetFiles, "Файлы пока не загружены.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
const sizeKb = Math.max(1, Math.round(Number(item.size_bytes || 0) / 1024));
p.textContent = item.file_name + " (" + sizeKb + " КБ)";
li.appendChild(p);
const link = document.createElement("a");
link.href = item.download_url;
link.textContent = "Открыть / скачать";
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.color = "#f6d7a8";
li.appendChild(link);
cabinetFiles.appendChild(li);
});
}
function renderInvoices(items) {
cabinetInvoices.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetInvoices, "Счета пока не выставлены.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = "Сформирован: " + formatDate(item.issued_at);
li.appendChild(time);
const p = document.createElement("p");
const amount = Number(item.amount || 0).toLocaleString("ru-RU");
p.textContent =
(item.invoice_number || "Счет") +
" • " +
(item.status_label || item.status || "-") +
" • " +
amount +
" " +
(item.currency || "RUB");
li.appendChild(p);
const link = document.createElement("a");
link.href = item.download_url;
link.textContent = "Открыть / скачать PDF";
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.color = "#f6d7a8";
li.appendChild(link);
cabinetInvoices.appendChild(li);
});
}
function renderTimeline(items) {
cabinetTimeline.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetTimeline, "История пока пуста.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = formatDate(item.created_at);
li.appendChild(time);
const p = document.createElement("p");
if (item.type === "status_change") {
p.textContent = "Статус: " + (item.payload?.from_status || "NEW") + " -> " + (item.payload?.to_status || "-");
} else if (item.type === "message") {
const author = item.payload?.author_name || item.payload?.author_type || "Участник";
p.textContent = "Сообщение от " + author + ": " + (item.payload?.body || "");
} else if (item.type === "attachment") {
p.textContent = "Файл: " + (item.payload?.file_name || "вложение");
} else {
p.textContent = "Событие";
}
li.appendChild(p);
cabinetTimeline.appendChild(li);
});
}
async function loadQuotes() {
try {
const response = await fetch("/api/public/quotes?limit=8&order=random");
if (!response.ok) throw new Error("quotes fetch failed");
const items = await response.json();
if (!Array.isArray(items) || items.length === 0) throw new Error("quotes empty");
let index = 0;
const render = () => {
const quote = items[index % items.length];
quoteText.textContent = quote.text;
quoteMeta.textContent = [quote.author, quote.source].filter(Boolean).join(" • ");
index += 1;
};
render();
if (items.length > 1) setInterval(render, 5500);
} catch (error) {
quoteText.textContent = "С вами работает дружный коллектив профессионалов. Мы уверены в вашем успехе.";
quoteMeta.textContent = "Команда компании";
}
}
async function fetchRequestByTrack(trackNumber) {
const response = await fetch("/api/public/requests/" + encodeURIComponent(trackNumber));
const data = await parseJsonSafe(response);
return { response, data };
}
async function ensureViewAccess(trackNumber) {
let { response, data } = await fetchRequestByTrack(trackNumber);
if (response.ok) return data;
if (response.status !== 401 && response.status !== 403) {
throw new Error(apiErrorDetail(data, "Не удалось открыть заявку"));
}
setStatus(cabinetStatus, "Отправляем OTP-код...", null);
const sendResponse = await fetch("/api/public/otp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "VIEW_REQUEST",
track_number: trackNumber
})
});
const sendData = await parseJsonSafe(sendResponse);
if (!sendResponse.ok) {
throw new Error(apiErrorDetail(sendData, "Не удалось отправить OTP"));
}
const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):");
if (!code) {
throw new Error("Код OTP не введен");
}
setStatus(cabinetStatus, "Проверяем OTP...", null);
const verifyResponse = await fetch("/api/public/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "VIEW_REQUEST",
track_number: trackNumber,
code: String(code).trim()
})
});
const verifyData = await parseJsonSafe(verifyResponse);
if (!verifyResponse.ok) {
throw new Error(apiErrorDetail(verifyData, "OTP не подтвержден"));
}
({ response, data } = await fetchRequestByTrack(trackNumber));
if (!response.ok) {
throw new Error(apiErrorDetail(data, "Нет доступа к заявке"));
}
return data;
}
async function refreshCabinetData() {
if (!activeTrack) return;
const [messagesRes, filesRes, invoicesRes, timelineRes] = await Promise.all([
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/attachments"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/invoices"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline")
]);
const messagesData = await parseJsonSafe(messagesRes);
const filesData = await parseJsonSafe(filesRes);
const invoicesData = await parseJsonSafe(invoicesRes);
const timelineData = await parseJsonSafe(timelineRes);
if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения"));
if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы"));
if (!invoicesRes.ok) throw new Error(apiErrorDetail(invoicesData, "Не удалось загрузить счета"));
if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю"));
renderMessages(messagesData);
renderFiles(filesData);
renderInvoices(invoicesData);
renderTimeline(timelineData);
}
async function openCabinetByTrack() {
const trackNumber = String(cabinetTrackInput.value || "").trim().toUpperCase();
if (!trackNumber) {
setStatus(cabinetStatus, "Введите номер заявки.", "error");
return;
}
try {
setStatus(cabinetStatus, "Открываем кабинет...", null);
const requestData = await ensureViewAccess(trackNumber);
activeTrack = trackNumber;
activeRequestId = requestData.id;
cabinetRequestStatus.textContent = requestData.status_code || "-";
cabinetRequestTopic.textContent = requestData.topic_code || "Не указана";
cabinetRequestCreated.textContent = formatDate(requestData.created_at);
cabinetRequestUpdated.textContent = formatDate(requestData.updated_at);
cabinetSummary.hidden = false;
setCabinetEnabled(true);
await refreshCabinetData();
setStatus(cabinetStatus, "Кабинет открыт: " + trackNumber, "ok");
} catch (error) {
setStatus(cabinetStatus, error?.message || "Не удалось открыть кабинет", "error");
}
}
cabinetOpenButton.addEventListener("click", () => {
openCabinetByTrack();
}); });
const otpSendData = await parseJsonSafe(otpSend);
if (!otpSend.ok) throw new Error(apiErrorDetail(otpSendData, "Не удалось отправить OTP"));
cabinetChatForm.addEventListener("submit", async (event) => { const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):");
event.preventDefault(); if (!code) throw new Error("Код OTP не введен");
if (!activeTrack) {
setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error");
return;
}
const body = String(cabinetChatBody.value || "").trim(); setStatus(requestStatus, "Проверяем OTP...", null);
if (!body) return; const otpVerify = await fetch("/api/public/otp/verify", {
method: "POST",
try { headers: { "Content-Type": "application/json" },
setStatus(cabinetStatus, "Отправляем сообщение...", null); body: JSON.stringify({
const response = await fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages", { purpose: "CREATE_REQUEST",
method: "POST", client_phone: payload.client_phone,
headers: { "Content-Type": "application/json" }, code: String(code).trim(),
body: JSON.stringify({ body }) }),
});
const data = await parseJsonSafe(response);
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить сообщение"));
cabinetChatBody.value = "";
await refreshCabinetData();
setStatus(cabinetStatus, "Сообщение отправлено.", "ok");
} catch (error) {
setStatus(cabinetStatus, error?.message || "Ошибка отправки сообщения", "error");
}
}); });
const otpVerifyData = await parseJsonSafe(otpVerify);
if (!otpVerify.ok) throw new Error(apiErrorDetail(otpVerifyData, "OTP не подтвержден"));
cabinetFileUpload.addEventListener("click", async () => { setStatus(requestStatus, "Создаем заявку...", null);
if (!activeTrack || !activeRequestId) { const response = await fetch("/api/public/requests", {
setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error"); method: "POST",
return; headers: { "Content-Type": "application/json" },
} body: JSON.stringify(payload),
const file = cabinetFileInput.files && cabinetFileInput.files[0];
if (!file) {
setStatus(cabinetStatus, "Выберите файл для загрузки.", "error");
return;
}
try {
setStatus(cabinetStatus, "Подготавливаем загрузку файла...", null);
const initResponse = await fetch("/api/public/uploads/init", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_name: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: activeRequestId
})
});
const initData = await parseJsonSafe(initResponse);
if (!initResponse.ok) throw new Error(apiErrorDetail(initData, "Не удалось начать загрузку"));
const putResponse = await fetch(initData.presigned_url, {
method: "PUT",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file
});
if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище");
const completeResponse = await fetch("/api/public/uploads/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: initData.key,
file_name: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: activeRequestId
})
});
const completeData = await parseJsonSafe(completeResponse);
if (!completeResponse.ok) throw new Error(apiErrorDetail(completeData, "Не удалось завершить загрузку"));
cabinetFileInput.value = "";
await refreshCabinetData();
setStatus(cabinetStatus, "Файл загружен.", "ok");
} catch (error) {
setStatus(cabinetStatus, error?.message || "Ошибка загрузки файла", "error");
}
}); });
const data = await parseJsonSafe(response);
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось создать заявку"));
form.addEventListener("submit", async (event) => { setStatus(requestStatus, "Заявка принята. Номер: " + data.track_number, "ok");
event.preventDefault(); requestForm.reset();
setStatus(status, "Отправляем заявку...", null); setTimeout(() => closeModal(requestModal), 1200);
} catch (error) {
setStatus(requestStatus, error?.message || "Не удалось отправить заявку. Повторите попытку позже.", "error");
}
});
const payload = { loadTopics();
client_name: document.getElementById("name").value.trim(), loadQuotes();
client_phone: document.getElementById("phone").value.trim(), })();
topic_code: "consulting",
description: document.getElementById("description").value.trim(),
extra_fields: {
referral_name: document.getElementById("referral").value.trim()
}
};
try {
setStatus(status, "Отправляем OTP-код...", null);
const otpSend = await fetch("/api/public/otp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "CREATE_REQUEST",
client_phone: payload.client_phone
})
});
if (!otpSend.ok) throw new Error("otp send failed");
const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):");
if (!code) throw new Error("otp code required");
setStatus(status, "Проверяем OTP...", null);
const otpVerify = await fetch("/api/public/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purpose: "CREATE_REQUEST",
client_phone: payload.client_phone,
code: String(code).trim()
})
});
if (!otpVerify.ok) throw new Error("otp verify failed");
setStatus(status, "Создаем заявку...", null);
const response = await fetch("/api/public/requests", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error("create request failed");
const data = await response.json();
setStatus(status, "Заявка принята. Номер: " + data.track_number, "ok");
cabinetTrackInput.value = data.track_number;
form.reset();
setTimeout(closeModal, 1200);
} catch (error) {
setStatus(status, "Не удалось отправить заявку. Повторите попытку позже.", "error");
}
});
loadQuotes();
setCabinetEnabled(false);
clearList(cabinetMessages, "Сообщений пока нет.");
clearList(cabinetFiles, "Файлы пока не загружены.");
clearList(cabinetInvoices, "Счета пока не выставлены.");
clearList(cabinetTimeline, "История пока пуста.");
})();

Binary file not shown.

View file

@ -46,6 +46,18 @@
| P25 | сделано | Биллинг-статус | Добавить тип статуса «выставление счета»: генерация счета из шаблона, отправка клиенту и фиксация события оплаты по смене статуса администратором на `Оплачено` | Для темы можно включить billing-этап, счет формируется и доставляется; факт оплаты фиксируется по событиям `Оплачено` (возможны множественные события в одной заявке) | | P25 | сделано | Биллинг-статус | Добавить тип статуса «выставление счета»: генерация счета из шаблона, отправка клиенту и фиксация события оплаты по смене статуса администратором на `Оплачено` | Для темы можно включить billing-этап, счет формируется и доставляется; факт оплаты фиксируется по событиям `Оплачено` (возможны множественные события в одной заявке) |
| P26 | сделано | Security Audit | Внедрить аудит безопасности и защиту ПДн для S3/файлов по требованиям РФ и кибербезопасности | Реализован журнал доступа, шифрование, RBAC/least-privilege, политика хранения и контроль инцидентов | | P26 | сделано | Security Audit | Внедрить аудит безопасности и защиту ПДн для S3/файлов по требованиям РФ и кибербезопасности | Реализован журнал доступа, шифрование, RBAC/least-privilege, политика хранения и контроль инцидентов |
| P27 | сделано | Итоговое тестирование E2E | Покрыть ключевые бизнес-сценарии: OTP, claim, auto-assign v2, чат, файлы, SLA, уведомления, read markers и выполнить финальный регрессионный прогон | Набор автотестов фиксирует регрессии критичных сценариев и подтверждает готовность перед приемкой | | P27 | сделано | Итоговое тестирование E2E | Покрыть ключевые бизнес-сценарии: OTP, claim, auto-assign v2, чат, файлы, SLA, уведомления, read markers и выполнить финальный регрессионный прогон | Набор автотестов фиксирует регрессии критичных сценариев и подтверждает готовность перед приемкой |
| P28 | сделано | Справочники: все таблицы БД | Обеспечить отображение в «Справочниках» всех таблиц БД (кроме выделенных разделов «Заявки» и «Счета»), включая `clients` при наличии отдельной таблицы клиентов | Создана таблица `clients` через миграцию, добавлены ссылки в `requests`/`invoices`, справочник `clients` доступен через universal meta |
| P29 | сделано | Модальная форма заявки | Оставить создание заявки только в модальном окне; убрать дублирующие/вводящие в заблуждение блоки с формы и лендинга, вернуть явный выбор темы обращения | На лендинге модалка создания заявки содержит выбор темы; блок и поле рекомендаций удалены |
| P30 | сделано | Отдельная страница клиента | Вынести работу с заявкой клиента (статус, чат, файлы, счета, таймлайн) на отдельную страницу, а не в нижний блок лендинга | Реализована отдельная страница `client.html` с полным рабочим контуром заявки |
| P31 | сделано | Вход клиента через OTP-модалку | Добавить на лендинг кнопку перехода в страницу работы с заявкой через модалку авторизации: телефон + OTP (если нет валидной 7-дневной cookie/JWT) | На лендинге добавлена OTP-модалка входа по телефону; при валидной сессии переход в клиентскую страницу выполняется сразу |
| P32 | сделано | Переключение между заявками клиента | Спроектировать и реализовать переключение между несколькими заявками авторизованного клиента на странице работы с заявкой | На странице клиента добавлен селектор «Мои заявки» и серверный endpoint `/api/public/requests/my` |
| P33 | сделано | Выделенный сервис чата | Выделить чат (клиент↔юрист) в отдельный сервис/контур с API-границей, сохранив текущие бизнес-правила, RBAC и read/unread поведение | Добавлен отдельный сервисный слой `chat_service` и отдельные API-контуры `/api/public/chat/*`, `/api/admin/chat/*`; UI и тесты переведены и проходят |
| P34 | сделано | UX цитат в «Первая консультация» | Перенести цитаты в ненавязчивый формат внутри блока «Первая консультация», убрать визуальный шум и конкуренцию с основным CTA | Цитаты перенесены в блок «Первая консультация» в hero-панели как компактный элемент без конкуренции с CTA |
| P35 | сделано | Предпросмотр документов | Добавить предпросмотр загруженных документов (pdf/jpg/mp4) в модальном окне или выделенной зоне страницы заявки, не ломая текущую загрузку/скачивание | Предпросмотр реализован в `client.html` и в рабочей вкладке заявки `admin.jsx` (`/admin.html?view=request&requestId=...`); сохранено действие «Открыть / скачать», добавлен backend тест inline-preview |
| P36 | сделано | Навигация в админ-панель | Убрать кнопку «Админ-панель» с лендинга, исправить редиректы/роутинг (`/admin`, `/admin.html`) чтобы не было перехода на неверный host/port | Кнопка админки удалена с лендинга; `/admin` корректно переводит на `/admin.html`; добавлен e2e smoke `admin_entry_flow` |
| P37 | сделано | Админ-авторизация и креды | Привести к единому правилу bootstrap-креды администратора (`admin@example.com` + согласованный пароль), обновить документацию/контекст и smoke-проверки логина | Реализован bootstrap-login с автосозданием администратора `admin@example.com` / `admin123`; добавлены автотесты `tests/test_admin_auth.py` |
| P38 | к разработке | Конструктор маршрутов статусов | Реализовать для администратора визуальный конструктор маршрутов статусов по каждой теме: вариативные переходы (в т.ч. возврат на предыдущий статус, переход в завершение и альтернативные ветки), SLA на переход, список обязательных документов/данных для закрытия шага | Админ может собрать/изменить граф переходов для темы, задать SLA и требования на каждом шаге; API валидирует переходы и требования, UI отображает и редактирует граф без ручного JSON |
| P39 | к разработке | Канбан по заявкам (LAWYER/ADMIN) | Реализовать канбан-доску заявок с унификацией разных статусных флоу через группы колонок (например: `Новые`, `В работе`, `Ожидание`, `Завершены`) + карточки заявок с ключевыми данными (дата создания, клиент, описание, новые сообщения/файлы, SLA deadline/дедлайн дела) | Для `LAWYER` видны свои + неназначенные заявки, для `ADMIN` — все юристы и заявки; карточки перетаскиваются/переводятся между допустимыми этапами с серверной валидацией |
## Критический маршрут (обязательный порядок) ## Критический маршрут (обязательный порядок)
1. `P07 -> P08 -> P09 -> P10` (полный контур назначения). 1. `P07 -> P08 -> P09 -> P10` (полный контур назначения).
@ -53,6 +65,33 @@
3. `P14 -> P15 -> P16` (процесс работы по заявке). 3. `P14 -> P15 -> P16` (процесс работы по заявке).
4. `P17 -> P18 -> P24 -> P25 -> P19 -> P20 -> P21` (файлы, SLA, тарифы/биллинг, аналитика). 4. `P17 -> P18 -> P24 -> P25 -> P19 -> P20 -> P21` (файлы, SLA, тарифы/биллинг, аналитика).
5. `P22 -> P23 -> P26 -> P27` (стабилизация, mobile UX, security-аудит, итоговые тесты в конце). 5. `P22 -> P23 -> P26 -> P27` (стабилизация, mobile UX, security-аудит, итоговые тесты в конце).
6. `P28 -> P29 -> P30 -> P31 -> P32 -> P33 -> P35 -> P34 -> P36 -> P37` (итерация UX/клиентского входа/чат-сервиса/навигации/доступов).
7. `P38 -> P39` (конструктор маршрутов и канбан-представление заявок для ролей).
## Детализация P38-P39 (новый контур)
### P38. Конструктор маршрутов статусов
1. Граф переходов по теме:
хранение `from_status -> to_status`, признак `enabled`, `sort_order`, `sla_hours`.
2. Вариативные переходы:
поддержать переходы назад, терминальные выходы и параллельные ветки.
3. Требования на шаг:
для каждого целевого статуса хранить список обязательных документов/данных для закрытия этапа.
4. Валидация:
серверно проверять допустимость перехода и наличие обязательных данных перед сменой статуса.
5. UI конструктора:
визуальный редактор узлов/связей + форма параметров шага (SLA, требования, terminal).
### P39. Канбан заявок
1. Унификация статусов:
ввести группировку статусов в канбан-колонки (`НОВЫЕ`, `В РАБОТЕ`, `ОЖИДАНИЕ`, `ЗАВЕРШЕНЫ`).
2. Маппинг:
каждый статус темы привязать к одной канбан-группе (конфигурируемо администратором).
3. Ролевой scope:
`LAWYER` видит свои + неназначенные заявки, `ADMIN` — все заявки всех юристов.
4. Карточка канбана:
`track_number`, дата создания, клиент, краткое описание, индикаторы новых сообщений/файлов, SLA-дедлайн/дата дела.
5. Действия:
перевод карточки между колонками только по допустимым серверным переходам.
## Правила выполнения для ИИ-агента ## Правила выполнения для ИИ-агента
1. Не менять бизнес-правила без обновления `context/*.md`. 1. Не менять бизнес-правила без обновления `context/*.md`.
@ -62,3 +101,4 @@
5. Для операций назначения использовать транзакционную защиту от гонок. 5. Для операций назначения использовать транзакционную защиту от гонок.
6. Для статусов и SLA использовать только серверную валидацию (не доверять фронту). 6. Для статусов и SLA использовать только серверную валидацию (не доверять фронту).
7. Перед переводом пункта в `сделано` выполнять проверки из `context/11_test_runbook.md`. 7. Перед переводом пункта в `сделано` выполнять проверки из `context/11_test_runbook.md`.
8. UI e2e запускать через фиксированный compose-сервис `e2e` (образ `law-e2e-playwright:1.58.2`) для стабильного повторяемого прогона без повторной загрузки браузеров.

View file

@ -1,7 +1,7 @@
# Runbook Проверок (Тесты и Валидация по Плану) # Runbook Проверок (Тесты и Валидация по Плану)
## Назначение ## Назначение
Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P27` и как их запускать. Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P39` и как их запускать.
Использовать перед переводом пункта в статус `сделано`. Использовать перед переводом пункта в статус `сделано`.
## Базовые команды ## Базовые команды
@ -22,10 +22,19 @@ docker compose exec -T backend python -m compileall app tests alembic
docker compose build frontend docker compose build frontend
docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cache nodejs npm >/dev/null && npx --yes esbuild /usr/share/nginx/html/admin.jsx --loader:.jsx=jsx --bundle --outfile=/tmp/admin.bundle.js" docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cache nodejs npm >/dev/null && npx --yes esbuild /usr/share/nginx/html/admin.jsx --loader:.jsx=jsx --bundle --outfile=/tmp/admin.bundle.js"
``` ```
5. Браузерный E2E (Playwright) для ролевых UI-флоу (PUBLIC / LAWYER / ADMIN): 5. Браузерный E2E (Playwright) для ролевых UI-флоу (PUBLIC / LAWYER / ADMIN) через фиксированный образ `law-e2e-playwright:1.58.2`:
```bash ```bash
docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft.com/playwright:v1.58.2-jammy sh -lc "npm install --silent && E2E_BASE_URL=http://frontend E2E_ADMIN_EMAIL=admin@example.com E2E_ADMIN_PASSWORD='AdminPass-123!' E2E_LAWYER_EMAIL=ivan@mail.ru E2E_LAWYER_PASSWORD='LawyerPass-123!' npx playwright test --config=playwright.config.js" docker compose build e2e
docker compose run --rm --no-deps e2e playwright --version
docker compose run --rm --no-deps \
-e E2E_BASE_URL=http://frontend \
-e E2E_ADMIN_EMAIL=admin@example.com \
-e E2E_ADMIN_PASSWORD='admin123' \
-e E2E_LAWYER_EMAIL=ivan@mail.ru \
-e E2E_LAWYER_PASSWORD='LawyerPass-123!' \
e2e playwright test --config=playwright.config.js
``` ```
Примечание: образ `e2e` собирается один раз и переиспользуется, браузеры/Playwright не скачиваются при каждом запуске.
## Матрица проверок по задачам ## Матрица проверок по задачам
| ID | Что проверяем | Где тесты | Как запускать | | ID | Что проверяем | Где тесты | Как запускать |
@ -56,7 +65,19 @@ docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft
| P24 | Ставки юриста и ставка заявки | `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_rates tests.test_admin_universal_crud -v`; проверка что public API не отдает поля ставок/процентов | | P24 | Ставки юриста и ставка заявки | `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_rates tests.test_admin_universal_crud -v`; проверка что public API не отдает поля ставок/процентов |
| P25 | Billing-статус и шаблон счета | `tests/test_billing_flow.py`, `tests/test_invoices.py` + e2e статусных переходов | `docker compose exec -T backend python -m unittest tests.test_billing_flow tests.test_invoices tests.test_admin_universal_crud -v`; валидация автогенерации счета при billing-статусе и фиксации оплаты только при ADMIN->`Оплачено` (в т.ч. множественные оплаты в одной заявке) | | P25 | Billing-статус и шаблон счета | `tests/test_billing_flow.py`, `tests/test_invoices.py` + e2e статусных переходов | `docker compose exec -T backend python -m unittest tests.test_billing_flow tests.test_invoices tests.test_admin_universal_crud -v`; валидация автогенерации счета при billing-статусе и фиксации оплаты только при ADMIN->`Оплачено` (в т.ч. множественные оплаты в одной заявке) |
| P26 | Security audit S3/ПДн | `tests/test_security_audit.py` + `tests/test_uploads_s3.py` + `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_security_audit tests.test_uploads_s3 tests.test_migrations -v`; проверить события allow/deny в `security_audit_log` и применимость миграции `0014_security_audit_log` | | P26 | Security audit S3/ПДн | `tests/test_security_audit.py` + `tests/test_uploads_s3.py` + `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_security_audit tests.test_uploads_s3 tests.test_migrations -v`; проверить события allow/deny в `security_audit_log` и применимость миграции `0014_security_audit_log` |
| P27 | Итоговые E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + полный прогон | | P27 | Итоговые E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + прогон Playwright через сервис `e2e` (образ `law-e2e-playwright:1.58.2`) |
| P28 | Все таблицы БД в справочниках (+ `clients`, если добавляется) | `tests/test_admin_universal_crud.py`, `tests/test_migrations.py`, UI e2e admin dictionaries | миграции + `python -m unittest tests.test_admin_universal_crud tests.test_migrations -v` + e2e admin |
| P29 | Единая модальная форма заявки + тема обращения + удаление рекомендаций | `e2e/tests/public_client_flow.spec.js` + UI smoke лендинга | прогон Playwright через `docker compose run --rm --no-deps e2e ...` + ручная проверка текста/полей на лендинге |
| 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/test_admin_universal_crud.py` (chat service cases) + UI smoke (`client.js`, `admin.jsx`) | `docker compose run --rm backend python -m unittest tests.test_public_cabinet tests.test_admin_universal_crud -v` + фронт-сборка `admin.jsx` |
| 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` |
| P37 | Единые bootstrap-креды админа | `tests/test_admin_auth.py` + auth smoke (`/api/admin/auth/login`) + docs consistency check | `docker compose run --rm backend python -m unittest tests.test_admin_auth -v` + UI/API login smoke с `admin@example.com` / `admin123` |
| P38 | Конструктор маршрутов статусов (темы) | `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py` + новый e2e `e2e/tests/admin_status_designer_flow.spec.js` | backend: валидация графа переходов/SLA/требуемых документов; UI: создание/редактирование ветвлений, возвратов, терминальных переходов |
| P39 | Канбан заявок для LAWYER/ADMIN | `tests/test_admin_universal_crud.py`, `tests/test_dashboard_finance.py` + новые e2e `e2e/tests/lawyer_kanban_flow.spec.js`, `e2e/tests/admin_kanban_flow.spec.js` | Проверить группировку статусов, ролевой scope карточек, перемещение по допустимым переходам, отображение дедлайнов SLA и индикаторов новых сообщений/файлов |
## Ролевое покрытие (PUBLIC / LAWYER / ADMIN) ## Ролевое покрытие (PUBLIC / LAWYER / ADMIN)
### PUBLIC (клиент) ### PUBLIC (клиент)
@ -68,7 +89,7 @@ docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft
- Публичные счета и PDF в кабинете: `tests/test_invoices.py`. - Публичные счета и PDF в кабинете: `tests/test_invoices.py`.
### LAWYER (юрист) ### LAWYER (юрист)
- UI e2e: `e2e/tests/lawyer_role_flow.spec.js` (вход, claim неназначенной заявки, чтение обновлений, смена статуса). - UI e2e: `e2e/tests/lawyer_role_flow.spec.js` (вход, claim неназначенной заявки, новая вкладка работы с заявкой, чтение обновлений, смена статуса).
- Дашборд юриста (свои, неназначенные, непрочитанные): `tests/test_dashboard_finance.py`. - Дашборд юриста (свои, неназначенные, непрочитанные): `tests/test_dashboard_finance.py`.
- Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/test_admin_universal_crud.py`. - Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/test_admin_universal_crud.py`.
- Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `tests/test_admin_universal_crud.py`. - Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `tests/test_admin_universal_crud.py`.
@ -79,6 +100,8 @@ docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft
### ADMIN (администратор) ### ADMIN (администратор)
- UI e2e: `e2e/tests/admin_role_flow.spec.js` (вход, справочники, создание пользователя/темы, создание и оплата счета). - UI e2e: `e2e/tests/admin_role_flow.spec.js` (вход, справочники, создание пользователя/темы, создание и оплата счета).
- UI e2e entry/redirect smoke: `e2e/tests/admin_entry_flow.spec.js` (нет CTA админки на лендинге, вход через `/admin`).
- Bootstrap-auth: `tests/test_admin_auth.py` (автосоздание bootstrap-admin и негативные кейсы логина).
- CRUD пользователей/юристов (пароли, роли, профильная тема, аватар): `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py`. - CRUD пользователей/юристов (пароли, роли, профильная тема, аватар): `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py`.
- Темы и флоу статусов (включая ветвление), SLA-переходы: `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py`. - Темы и флоу статусов (включая ветвление), SLA-переходы: `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py`.
- Шаблоны обязательных/дозапрашиваемых данных: `tests/test_admin_universal_crud.py`, `tests/test_public_requests.py`. - Шаблоны обязательных/дозапрашиваемых данных: `tests/test_admin_universal_crud.py`, `tests/test_public_requests.py`.
@ -94,6 +117,9 @@ docker run --rm --network law_default -v "$PWD:/work" -w /work/e2e mcr.microsoft
5. Для изменений `admin.jsx` выполнить сборку `admin.jsx` через Docker Compose. 5. Для изменений `admin.jsx` выполнить сборку `admin.jsx` через Docker Compose.
6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`. 6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`.
## Последний регрессионный прогон ## Последние подтвержденные прогоны
- `python -m unittest discover -s tests -p 'test_*.py' -v``94 tests OK`. - `docker compose run --rm backend python -m unittest -v tests.test_admin_auth``3 passed`.
- `Playwright UI roles` (`e2e/tests/admin_role_flow.spec.js`, `e2e/tests/lawyer_role_flow.spec.js`, `e2e/tests/public_client_flow.spec.js`) — `3 passed`. - `docker compose run --rm backend python -m unittest discover -s tests -p 'test_*.py' -v``105 passed`.
- `docker compose run --rm backend python -m compileall app tests alembic` — успешно.
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test tests/admin_entry_flow.spec.js --config=playwright.config.js``1 passed`.
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend -e E2E_ADMIN_EMAIL=admin@example.com -e E2E_ADMIN_PASSWORD=admin123 -e E2E_LAWYER_EMAIL=ivan@mail.ru -e E2E_LAWYER_PASSWORD='LawyerPass-123!' e2e playwright test --config=playwright.config.js``4 passed` (рольовые e2e: `admin_entry_flow`, `admin_role_flow`, `lawyer_role_flow`, `public_client_flow`).

View file

@ -0,0 +1,49 @@
# Чекпоинт Контекста (24 февраля 2026)
## Цель документа
Зафиксировать фактический стейт после реализации блока `P28-P32` и подготовить остаток очереди `P33-P37`.
## Подтвержденный текущий стейт
- Добавлена обязательная сущность клиента: новая таблица `clients` и миграция `0015_clients_table_links`.
- В `requests` и `invoices` добавлены ссылки `client_id` + серверная логика автопривязки клиента по телефону.
- В public API добавлены:
- `GET /api/public/requests/topics` (темы для формы заявки),
- `GET /api/public/requests/my` (список заявок авторизованного клиента),
- phone-based VIEW OTP (`/api/public/otp/send|verify` с `client_phone`).
- Доступ к заявке в public-контуре поддерживает оба сценария:
- legacy по `track_number`,
- новый по `client_phone` с переключением между заявками.
- Лендинг обновлен:
- форма создания заявки остается модальной,
- блок и поле рекомендаций удалены,
- выбор темы возвращен в форму,
- добавлена OTP-модалка входа на клиентскую страницу.
- Реализована отдельная страница клиента `client.html` (статус, чат, файлы, счета, таймлайн, переключение между заявками).
- Реализован предпросмотр вложений (`pdf/jpg/mp4`) в клиентском кабинете и в рабочей вкладке заявки юриста/админа.
- Legacy-модалка заявки в админке удалена: работа по заявке ведется через отдельную вкладку `/admin.html?view=request&requestId=...` с breadcrumb-навигацией.
- Зафиксирован Docker-образ для UI E2E: `law-e2e-playwright:1.58.2` (service `e2e` в `docker-compose`), чтобы не скачивать Playwright/браузеры на каждом прогоне.
- Цитаты перенесены в ненавязчивый формат в блок «Первая консультация» (hero panel).
- Удалена кнопка «Админ-панель» с лендинга; вход в админку выполняется через маршрут `/admin` -> `/admin.html`.
- Добавлен bootstrap-login администратора (`admin@example.com` / `admin123`) с автосозданием пользователя при первом входе.
## Проверка реализации P28-P32
1. **Справочники и таблица клиентов**:
- Таблица `clients` добавлена миграцией.
- `admin/crud/meta/tables` теперь включает `clients`.
2. **Модалка заявки**:
- Поле рекомендации удалено.
- Добавлен выбор темы обращения.
3. **Отдельная страница клиента**:
- Кабинет вынесен в `client.html` + `client.js` + `client.css`.
4. **OTP вход по телефону и переход на страницу**:
- На лендинге добавлена модалка phone+OTP.
- При валидной сессии переход выполняется напрямую.
5. **Переключение между заявками**:
- На `client.html` реализован селектор заявок по endpoint `/api/public/requests/my`.
## Привязка к следующей итерации
- `P33` — выполнен: чат вынесен в отдельный сервисный слой и отдельные API-контуры (`/api/public/chat`, `/api/admin/chat`).
- `P34` — выполнен: цитаты перенесены в ненавязчивый вид в блок «Первая консультация».
- `P35` — выполнен: добавлен UI preview + backend тест `test_public_attachment_object_preview_returns_inline_response`.
- `P36` — выполнен: удалена админ-кнопка с лендинга, добавлен e2e smoke `admin_entry_flow`, редирект `/admin` валидирован.
- `P37` — выполнен: единый стандарт админ-кредов, реализован bootstrap-login и автотесты `tests/test_admin_auth.py`.

View file

@ -7,6 +7,21 @@ services:
depends_on: [backend] depends_on: [backend]
ports: ["8081:80"] ports: ["8081:80"]
e2e:
build:
context: .
dockerfile: e2e/Dockerfile
image: law-e2e-playwright:1.58.2
container_name: law-e2e
working_dir: /src/e2e
depends_on: [frontend]
volumes:
- .:/src
- /src/e2e/node_modules
environment:
NODE_PATH: /opt/e2e/node_modules
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
backend: backend:
build: . build: .
container_name: law-backend container_name: law-backend

10
e2e/Dockerfile Normal file
View file

@ -0,0 +1,10 @@
FROM mcr.microsoft.com/playwright:v1.58.2-jammy
WORKDIR /opt/e2e
COPY e2e/package.json e2e/package-lock.json ./
RUN npm ci --no-audit --no-fund
ENV NODE_PATH=/opt/e2e/node_modules
ENV PATH=/opt/e2e/node_modules/.bin:$PATH
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1

246
e2e/package-lock.json generated Normal file
View file

@ -0,0 +1,246 @@
{
"name": "law-e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "law-e2e",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "1.58.2",
"dotenv": "^16.4.5",
"jsonwebtoken": "^9.0.2"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"dev": true,
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"dev": true,
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"dev": true,
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
}
}
}

29
e2e/playwright.config.js Normal file
View file

@ -0,0 +1,29 @@
const { defineConfig } = require("@playwright/test");
const chromiumPath = process.env.E2E_CHROMIUM_PATH || "";
module.exports = defineConfig({
testDir: "./tests",
timeout: 90_000,
workers: 1,
expect: {
timeout: 15_000,
},
fullyParallel: false,
retries: 0,
reporter: [
["list"],
["html", { outputFolder: "playwright-report", open: "never" }],
],
use: {
baseURL: process.env.E2E_BASE_URL || "http://localhost:8081",
headless: true,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
launchOptions: chromiumPath
? {
executablePath: chromiumPath,
}
: undefined,
},
});

View file

@ -0,0 +1,13 @@
const { test, expect } = require("@playwright/test");
test("admin entry via route only: landing has no admin CTA and /admin opens panel", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("link", { name: "Админ-панель" })).toHaveCount(0);
await page.goto("/admin");
await expect(async () => {
const loginVisible = await page.locator("#login-email").isVisible().catch(() => false);
const panelVisible = await page.getByRole("heading", { name: "Панель администратора" }).isVisible().catch(() => false);
expect(loginVisible || panelVisible).toBeTruthy();
}).toPass({ timeout: 30_000 });
});

View file

@ -0,0 +1,110 @@
const { test, expect } = require("@playwright/test");
const {
preparePublicSession,
createRequestViaLanding,
randomPhone,
loginAdminPanel,
openDictionaryTree,
selectDictionaryNode,
rowByTrack,
} = require("./helpers");
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
test("admin flow via UI: dictionaries + users + topics + invoices", async ({ context, page }) => {
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
const phone = randomPhone();
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Заявка для проверки админского UI-флоу",
});
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
await expect(page.locator(".badge")).toContainText("роль: Администратор");
await expect(page.locator("#section-dashboard h2")).toHaveText("Обзор метрик");
await expect(page.locator("#section-dashboard")).toContainText("Загрузка юристов");
await openDictionaryTree(page);
await expect(page.locator("aside .menu .menu-tree")).toContainText("Темы");
await expect(page.locator("aside .menu .menu-tree")).toContainText("Статусы");
await expect(page.locator("aside .menu .menu-tree")).toContainText("Переходы статусов");
await expect(page.locator("aside .menu .menu-tree")).toContainText("Пользователи");
await expect(page.locator("aside .menu .menu-tree")).toContainText("Цитаты");
const unique = Date.now();
const lawyerEmail = `ui-lawyer-${unique}@example.com`;
const topicName = `Тема UI ${unique}`;
await selectDictionaryNode(page, "Пользователи");
await page.locator("#section-config .config-panel").getByRole("button", { name: "Добавить" }).click();
await expect(page.getByRole("heading", { name: /Создание • Пользователи/ })).toBeVisible();
await page.locator("#record-field-name").fill(`Юрист UI ${unique}`);
await page.locator("#record-field-email").fill(lawyerEmail);
await page.locator("#record-field-role").selectOption("LAWYER");
await page.locator("#record-field-default_rate").fill("5000");
await page.locator("#record-field-salary_percent").fill("35");
await page.locator("#record-field-password").fill("UiLawyerPass-123!");
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
await expect(page.locator("#section-config .status").first()).toContainText("Список обновлен");
await expect(page.locator("#section-config table")).toContainText(lawyerEmail);
await selectDictionaryNode(page, "Темы");
await page.locator("#section-config .config-panel").getByRole("button", { name: "Добавить" }).click();
await expect(page.getByRole("heading", { name: /Создание • Темы/ })).toBeVisible();
await page.locator("#record-field-name").fill(topicName);
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
await expect(page.locator("#section-config .status").first()).toContainText("Список обновлен");
const topicRow = page.locator("#section-config table tbody tr").filter({ hasText: topicName });
await expect(topicRow).toHaveCount(1);
const topicCode = (await topicRow.first().locator("td code").innerText()).trim();
await page.locator("aside .menu button[data-section='invoices']").click();
await expect(page.locator("#section-invoices h2")).toHaveText("Счета");
await page.locator("#section-invoices").getByRole("button", { name: "Новый счет" }).click();
await expect(page.getByRole("heading", { name: /Создание • Счета/ })).toBeVisible();
await page.locator("#record-field-request_track_number").fill(trackNumber);
await page.locator("#record-field-amount").fill("15000");
await page.locator("#record-field-payer_display_name").fill("Тестовый плательщик");
await page.locator("#record-field-payer_details").fill('{"inn":"7700000000"}');
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
await expect(page.locator("#section-invoices .status")).toContainText("Список обновлен");
const invoiceRow = rowByTrack(page, "#section-invoices", trackNumber);
await expect(invoiceRow).toHaveCount(1);
await expect(invoiceRow.first()).toContainText("15000");
await invoiceRow.first().getByRole("button", { name: "Редактировать счет" }).click();
await expect(page.getByRole("heading", { name: /Редактирование • Счета/ })).toBeVisible();
await page.locator("#record-field-status").selectOption("PAID");
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
await expect(page.locator("#section-invoices .status")).toContainText("Список обновлен");
await expect(invoiceRow.first()).toContainText("Оплачен");
await page.goto("/admin.html?section=availableTables");
await expect(page.locator("#section-available-tables h2")).toHaveText("Доступность таблиц");
const clientsRow = page.locator("#section-available-tables table tbody tr").filter({ hasText: "clients" }).first();
await expect(clientsRow).toHaveCount(1);
const deactivateBtn = clientsRow.getByRole("button", { name: "Деактивировать таблицу" });
if (await deactivateBtn.count()) {
await deactivateBtn.click();
}
await expect(page.locator("#section-available-tables .status")).toContainText(/Сохранено|Список обновлен/);
await openDictionaryTree(page);
await expect(page.locator("aside .menu .menu-tree")).not.toContainText("Клиенты");
await page.goto("/admin.html?section=availableTables");
await expect(page.locator("#section-available-tables h2")).toHaveText("Доступность таблиц");
const clientsRowDisabled = page.locator("#section-available-tables table tbody tr").filter({ hasText: "clients" }).first();
await expect(clientsRowDisabled.getByRole("button", { name: "Активировать таблицу" })).toHaveCount(1);
await clientsRowDisabled.getByRole("button", { name: "Активировать таблицу" }).click();
await expect(page.locator("#section-available-tables .status")).toContainText(/Сохранено|Список обновлен/);
await openDictionaryTree(page);
await expect(page.locator("aside .menu .menu-tree")).toContainText("Клиенты");
});

215
e2e/tests/helpers.js Normal file
View file

@ -0,0 +1,215 @@
const path = require("path");
const jwt = require("jsonwebtoken");
const dotenv = require("dotenv");
const { expect } = require("@playwright/test");
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
const PUBLIC_SECRET = process.env.PUBLIC_JWT_SECRET || "change_me_public";
const PUBLIC_COOKIE_NAME = process.env.PUBLIC_COOKIE_NAME || "public_jwt";
function randomDigits(length) {
let value = "";
while (value.length < length) {
value += String(Math.floor(Math.random() * 10));
}
return value.slice(0, length);
}
function randomPhone() {
return `+79${randomDigits(9)}`;
}
function createPublicCookieToken(phone) {
return jwt.sign({ sub: phone, purpose: "CREATE_REQUEST" }, PUBLIC_SECRET, {
algorithm: "HS256",
expiresIn: "7d",
});
}
async function installPromptAutoAccept(page, code = "000000") {
page.on("dialog", async (dialog) => {
if (dialog.type() === "prompt") {
await dialog.accept(code);
return;
}
await dialog.accept();
});
}
async function installOtpBypassRoutes(page) {
await page.route("**/api/public/otp/send", async (route) => {
let purpose = "CREATE_REQUEST";
try {
const body = JSON.parse(route.request().postData() || "{}");
purpose = String(body.purpose || purpose);
} catch (_) {}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
status: "sent",
purpose,
ttl_seconds: 600,
sms_response: { provider: "e2e", status: "accepted", message: "ok" },
}),
});
});
await page.route("**/api/public/otp/verify", async (route) => {
let purpose = "CREATE_REQUEST";
try {
const body = JSON.parse(route.request().postData() || "{}");
purpose = String(body.purpose || purpose);
} catch (_) {}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "verified", purpose }),
});
});
}
async function preparePublicSession(context, page, appUrl, phone) {
await context.addCookies([
{
name: PUBLIC_COOKIE_NAME,
value: createPublicCookieToken(phone),
url: `${appUrl}/`,
httpOnly: true,
sameSite: "Lax",
},
]);
await installPromptAutoAccept(page);
await installOtpBypassRoutes(page);
}
async function createRequestViaLanding(page, options = {}) {
const phone = options.phone || randomPhone();
const name = options.name || `Клиент E2E ${Date.now()}`;
const description = options.description || "Проверка создания заявки через UI";
await page.goto("/");
await expect(page.getByRole("heading", { name: "Решаем сложные юридические задачи в интересах вашего бизнеса." })).toBeVisible();
await page.getByRole("button", { name: "Оставить заявку" }).first().click();
await expect(page.getByRole("heading", { name: "Создание заявки" })).toBeVisible();
await page.locator("#name").fill(name);
await page.locator("#phone").fill(phone);
const topicSelect = page.locator("#topic");
await topicSelect.waitFor();
await topicSelect.selectOption({ index: 1 });
await page.locator("#description").fill(description);
await page.getByRole("button", { name: "Отправить заявку" }).click();
await expect(page.locator("#form-status")).toContainText("Заявка принята. Номер:");
const statusText = await page.locator("#form-status").innerText();
const match = statusText.match(/TRK-[A-Z0-9-]+/);
if (!match) throw new Error("Track number not found in form status");
return { trackNumber: match[0], phone, name };
}
async function openPublicCabinet(page, trackNumber) {
await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
await expect(page.locator("#client-page-status")).toContainText(`Открыта заявка: ${trackNumber}`);
await expect(page.locator("#cabinet-summary")).toBeVisible();
await expect(page.locator("#cabinet-request-status")).not.toHaveText("-");
}
async function sendCabinetMessage(page, text) {
await page.locator("#cabinet-chat-body").fill(text);
await page.locator("#cabinet-chat-send").click();
await expect(page.locator("#client-page-status")).toContainText("Сообщение отправлено.");
await expect(page.locator("#cabinet-messages")).toContainText(text);
}
async function uploadCabinetFile(page, fileName = "e2e.txt", bodyText = "E2E file") {
let lastError = null;
for (let attempt = 1; attempt <= 2; attempt += 1) {
await page.locator("#cabinet-file-input").setInputFiles({
name: fileName,
mimeType: "application/pdf",
buffer: Buffer.from(bodyText, "utf-8"),
});
await page.locator("#cabinet-file-upload").click();
try {
await expect(page.locator("#client-page-status")).toContainText("Файл загружен.", { timeout: 20_000 });
lastError = null;
break;
} catch (error) {
lastError = error;
await page.waitForTimeout(500);
}
}
if (lastError) throw lastError;
await expect(page.locator("#cabinet-files")).toContainText(fileName);
}
async function loginAdminPanel(page, creds) {
await page.goto("/admin");
await expect(page.getByRole("heading", { name: "Панель администратора" })).toBeVisible();
let loginVisible = false;
const startedAt = Date.now();
while (Date.now() - startedAt < 15_000) {
loginVisible = await page.locator("#login-email").isVisible().catch(() => false);
if (loginVisible) break;
const badge = (await page.locator(".badge").first().textContent().catch(() => "")) || "";
if (badge && !badge.includes("роль: -")) break;
await page.waitForTimeout(200);
}
if (loginVisible) {
await page.locator("#login-email").fill(creds.email);
await page.locator("#login-password").fill(creds.password);
await page.getByRole("button", { name: "Войти" }).click();
}
await expect(page.getByRole("heading", { name: "Панель администратора" })).toBeVisible();
}
async function openRequestsSection(page) {
await page.locator("aside .menu button[data-section='requests']").click();
await expect(page.locator("#section-requests h2")).toHaveText("Заявки");
}
function rowByTrack(page, sectionSelector, trackNumber) {
return page.locator(`${sectionSelector} table tbody tr`).filter({ hasText: trackNumber });
}
async function openDictionaryTree(page) {
const treeButton = page.locator("aside .menu button", { hasText: "Справочники" }).first();
await treeButton.click();
const afterFirstClick = await treeButton.innerText();
if (afterFirstClick.includes("▸")) {
await treeButton.click();
}
await expect(page.locator("#section-config h2")).toHaveText("Справочники");
await expect(treeButton).toContainText("▾");
await expect.poll(async () => page.locator("aside .menu .menu-tree button").count(), { timeout: 30_000 }).toBeGreaterThan(0);
}
async function selectDictionaryNode(page, label) {
await page.locator("aside .menu .menu-tree").getByRole("button", { name: label, exact: true }).click();
await expect(page.locator("#section-config .config-panel h3")).toContainText(label);
}
module.exports = {
randomPhone,
preparePublicSession,
createRequestViaLanding,
openPublicCabinet,
sendCabinetMessage,
uploadCabinetFile,
loginAdminPanel,
openRequestsSection,
rowByTrack,
openDictionaryTree,
selectDictionaryNode,
};

View file

@ -0,0 +1,95 @@
const { test, expect } = require("@playwright/test");
const {
preparePublicSession,
createRequestViaLanding,
openPublicCabinet,
sendCabinetMessage,
uploadCabinetFile,
randomPhone,
loginAdminPanel,
openRequestsSection,
rowByTrack,
} = require("./helpers");
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
test("lawyer flow via UI: claim request -> chat and files in request workspace tab -> change status", async ({ context, page }) => {
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
const phone = randomPhone();
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Заявка для проверки флоу юриста через UI",
});
await openPublicCabinet(page, trackNumber);
await sendCabinetMessage(page, `Сообщение юристу ${Date.now()}`);
const clientFileName = `lawyer-client-${Date.now()}.txt`;
await uploadCabinetFile(page, clientFileName, "lawyer unread marker");
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
await expect(page.locator(".badge")).toContainText("роль: Юрист");
await openRequestsSection(page);
const row = rowByTrack(page, "#section-requests", trackNumber);
await expect(row).toHaveCount(1);
await expect(row.first().locator(".request-update-chip")).toBeVisible();
const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
await expect(claimBtn).toBeVisible();
await claimBtn.click();
await expect(page.locator("#section-requests .status")).toContainText(/Заявка взята в работу|Список обновлен/);
const requestPagePromise = context.waitForEvent("page");
await row.first().getByRole("button", { name: "Открыть заявку" }).click();
const requestPage = await requestPagePromise;
await requestPage.waitForLoadState("domcontentloaded");
await expect(requestPage.getByRole("heading", { name: "Карточка заявки" })).toBeVisible();
await expect(requestPage.locator("#section-request-workspace .breadcrumbs")).toContainText("Заявки -> Заявка");
await expect(requestPage.getByRole("button", { name: "Назад к заявкам" })).toBeVisible();
await expect(requestPage.locator("#request-modal-messages")).toContainText("Сообщение юристу");
await expect(requestPage.locator("#request-modal-files")).toContainText(clientFileName);
const clientFileRow = requestPage.locator("#request-modal-files li").filter({ hasText: clientFileName }).first();
await clientFileRow.getByRole("button", { name: /Предпросмотр/ }).click();
await expect(requestPage.locator("#request-file-preview-overlay")).toBeVisible();
await expect(requestPage.locator("#request-file-preview-overlay .request-preview-frame")).toBeVisible();
await requestPage.locator("#request-file-preview-overlay .close").click();
const lawyerMessage = `Ответ юриста ${Date.now()}`;
await requestPage.locator("#request-modal-message-body").fill(lawyerMessage);
await requestPage.locator("#request-modal-message-send").click();
await expect(requestPage.locator("#section-request-workspace .status")).toContainText("Сообщение отправлено");
await expect(requestPage.locator("#request-modal-messages")).toContainText(lawyerMessage);
const lawyerFileName = `lawyer-admin-${Date.now()}.pdf`;
await requestPage.locator("#request-modal-file-input").setInputFiles({
name: lawyerFileName,
mimeType: "application/pdf",
buffer: Buffer.from("lawyer file from admin modal", "utf-8"),
});
await requestPage.locator("#request-modal-file-upload").click();
await expect(requestPage.locator("#section-request-workspace .status")).toContainText("Файл загружен");
await expect(requestPage.locator("#request-modal-files")).toContainText(lawyerFileName);
await requestPage.close();
await page.locator("aside .menu button[data-section='requests']").click();
await expect(page.locator("#section-requests h2")).toHaveText("Заявки");
await page.locator("#section-requests").getByRole("button", { name: "Обновить" }).click();
await expect(row.first().locator(".request-update-empty")).toContainText("нет");
await row.first().getByRole("button", { name: "Редактировать заявку" }).click();
await expect(page.getByRole("heading", { name: /Редактирование • Заявки/ })).toBeVisible();
await page.locator("#record-field-status_code").selectOption("IN_PROGRESS");
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
await expect(page.locator("#section-requests .status")).toContainText("Список обновлен");
await expect(row.first()).toContainText("В работе");
await page.goto("/");
await openPublicCabinet(page, trackNumber);
await expect(page.locator("#cabinet-messages")).toContainText(lawyerMessage);
await expect(page.locator("#cabinet-files")).toContainText(lawyerFileName);
});

View file

@ -0,0 +1,33 @@
const { test } = require("@playwright/test");
const {
preparePublicSession,
createRequestViaLanding,
openPublicCabinet,
sendCabinetMessage,
uploadCabinetFile,
randomPhone,
} = require("./helpers");
test("public flow via UI: landing -> create request -> cabinet -> chat -> upload file", async ({ context, page }) => {
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
const phone = randomPhone();
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Проверка публичного E2E флоу через UI.",
});
await openPublicCabinet(page, trackNumber);
const message = `Сообщение из e2e ${Date.now()}`;
await sendCabinetMessage(page, message);
const uploadedFile = `public-${Date.now()}.pdf`;
await uploadCabinetFile(page, uploadedFile, "public file content");
const fileRow = page.locator("#cabinet-files .simple-item").filter({ hasText: uploadedFile }).first();
await fileRow.getByRole("button", { name: "Предпросмотр" }).click();
await page.locator("#file-preview-overlay #file-preview-body").waitFor();
await page.locator("#file-preview-close").click();
});

View file

@ -2,6 +2,7 @@ server {
listen 80; listen 80;
server_name _; server_name _;
server_tokens off; server_tokens off;
absolute_redirect off;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;

125
tests/test_admin_auth.py Normal file
View file

@ -0,0 +1,125 @@
import os
import unittest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.core.config import settings
from app.core.security import decode_jwt, hash_password
from app.db.session import get_db
from app.main import app
from app.models.admin_user import AdminUser
class AdminAuthTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(AdminUser))
db.commit()
def override_get_db():
db = self.SessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
self._settings_backup = {
"ADMIN_BOOTSTRAP_ENABLED": settings.ADMIN_BOOTSTRAP_ENABLED,
"ADMIN_BOOTSTRAP_EMAIL": settings.ADMIN_BOOTSTRAP_EMAIL,
"ADMIN_BOOTSTRAP_PASSWORD": settings.ADMIN_BOOTSTRAP_PASSWORD,
"ADMIN_BOOTSTRAP_NAME": settings.ADMIN_BOOTSTRAP_NAME,
}
settings.ADMIN_BOOTSTRAP_ENABLED = True
settings.ADMIN_BOOTSTRAP_EMAIL = "admin@example.com"
settings.ADMIN_BOOTSTRAP_PASSWORD = "admin123"
settings.ADMIN_BOOTSTRAP_NAME = "Администратор системы"
def tearDown(self):
self.client.close()
app.dependency_overrides.clear()
for key, value in self._settings_backup.items():
setattr(settings, key, value)
def test_login_bootstraps_admin_when_absent(self):
response = self.client.post(
"/api/admin/auth/login",
json={"email": "admin@example.com", "password": "admin123"},
)
self.assertEqual(response.status_code, 200)
token = response.json().get("access_token")
self.assertTrue(token)
claims = decode_jwt(token, settings.ADMIN_JWT_SECRET)
self.assertEqual(claims.get("email"), "admin@example.com")
self.assertEqual(claims.get("role"), "ADMIN")
with self.SessionLocal() as db:
row = db.query(AdminUser).filter(AdminUser.email == "admin@example.com").first()
self.assertIsNotNone(row)
self.assertEqual(row.role, "ADMIN")
self.assertTrue(bool(row.is_active))
def test_login_rejects_wrong_bootstrap_password(self):
response = self.client.post(
"/api/admin/auth/login",
json={"email": "admin@example.com", "password": "wrong-password"},
)
self.assertEqual(response.status_code, 401)
with self.SessionLocal() as db:
count = db.query(AdminUser).count()
self.assertEqual(count, 0)
def test_existing_admin_is_normalized_to_bootstrap_credentials(self):
with self.SessionLocal() as db:
db.add(
AdminUser(
role="ADMIN",
name="Администратор",
email="admin@example.com",
password_hash=hash_password("custom-pass-1"),
is_active=True,
)
)
db.commit()
ok = self.client.post(
"/api/admin/auth/login",
json={"email": "admin@example.com", "password": "admin123"},
)
self.assertEqual(ok.status_code, 200)
self.assertTrue(ok.json().get("access_token"))
wrong = self.client.post(
"/api/admin/auth/login",
json={"email": "admin@example.com", "password": "custom-pass-1"},
)
self.assertEqual(wrong.status_code, 401)

View file

@ -25,9 +25,11 @@ from app.models.admin_user import AdminUser
from app.models.admin_user_topic import AdminUserTopic from app.models.admin_user_topic import AdminUserTopic
from app.models.attachment import Attachment from app.models.attachment import Attachment
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.client import Client
from app.models.form_field import FormField from app.models.form_field import FormField
from app.models.message import Message from app.models.message import Message
from app.models.notification import Notification from app.models.notification import Notification
from app.models.table_availability import TableAvailability
from app.models.quote import Quote from app.models.quote import Quote
from app.models.request import Request from app.models.request import Request
from app.models.status import Status from app.models.status import Status
@ -49,6 +51,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
) )
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False) cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine) AdminUser.__table__.create(bind=cls.engine)
Client.__table__.create(bind=cls.engine)
Quote.__table__.create(bind=cls.engine) Quote.__table__.create(bind=cls.engine)
FormField.__table__.create(bind=cls.engine) FormField.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine) Request.__table__.create(bind=cls.engine)
@ -63,12 +66,14 @@ class AdminUniversalCrudTests(unittest.TestCase):
TopicStatusTransition.__table__.create(bind=cls.engine) TopicStatusTransition.__table__.create(bind=cls.engine)
AdminUserTopic.__table__.create(bind=cls.engine) AdminUserTopic.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine) Notification.__table__.create(bind=cls.engine)
TableAvailability.__table__.create(bind=cls.engine)
AuditLog.__table__.create(bind=cls.engine) AuditLog.__table__.create(bind=cls.engine)
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
AuditLog.__table__.drop(bind=cls.engine) AuditLog.__table__.drop(bind=cls.engine)
Notification.__table__.drop(bind=cls.engine) Notification.__table__.drop(bind=cls.engine)
TableAvailability.__table__.drop(bind=cls.engine)
AdminUserTopic.__table__.drop(bind=cls.engine) AdminUserTopic.__table__.drop(bind=cls.engine)
RequestDataRequirement.__table__.drop(bind=cls.engine) RequestDataRequirement.__table__.drop(bind=cls.engine)
TopicDataTemplate.__table__.drop(bind=cls.engine) TopicDataTemplate.__table__.drop(bind=cls.engine)
@ -82,6 +87,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
Request.__table__.drop(bind=cls.engine) Request.__table__.drop(bind=cls.engine)
FormField.__table__.drop(bind=cls.engine) FormField.__table__.drop(bind=cls.engine)
Quote.__table__.drop(bind=cls.engine) Quote.__table__.drop(bind=cls.engine)
Client.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine) AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose() cls.engine.dispose()
@ -92,6 +98,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
db.execute(delete(Attachment)) db.execute(delete(Attachment))
db.execute(delete(Message)) db.execute(delete(Message))
db.execute(delete(Request)) db.execute(delete(Request))
db.execute(delete(Client))
db.execute(delete(Status)) db.execute(delete(Status))
db.execute(delete(FormField)) db.execute(delete(FormField))
db.execute(delete(Topic)) db.execute(delete(Topic))
@ -101,6 +108,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
db.execute(delete(TopicStatusTransition)) db.execute(delete(TopicStatusTransition))
db.execute(delete(AdminUserTopic)) db.execute(delete(AdminUserTopic))
db.execute(delete(Notification)) db.execute(delete(Notification))
db.execute(delete(TableAvailability))
db.execute(delete(Quote)) db.execute(delete(Quote))
db.execute(delete(AdminUser)) db.execute(delete(AdminUser))
db.commit() db.commit()
@ -177,6 +185,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
by_table = {row["table"]: row for row in tables} by_table = {row["table"]: row for row in tables}
self.assertIn("requests", by_table) self.assertIn("requests", by_table)
self.assertIn("invoices", by_table) self.assertIn("invoices", by_table)
self.assertIn("clients", by_table)
self.assertIn("quotes", by_table) self.assertIn("quotes", by_table)
self.assertIn("statuses", by_table) self.assertIn("statuses", by_table)
@ -191,7 +200,12 @@ class AdminUniversalCrudTests(unittest.TestCase):
self.assertEqual(quotes_columns["sort_order"]["label"], "Порядок") self.assertEqual(quotes_columns["sort_order"]["label"], "Порядок")
self.assertTrue(all(str(col.get("label") or "").strip() for col in (by_table["quotes"].get("columns") or []))) self.assertTrue(all(str(col.get("label") or "").strip() for col in (by_table["quotes"].get("columns") or [])))
for table_name, table_meta in by_table.items(): for table_name, table_meta in by_table.items():
expected_section = "main" if table_name in {"requests", "invoices"} else "dictionary" if table_name in {"requests", "invoices"}:
expected_section = "main"
elif table_name == "table_availability":
expected_section = "system"
else:
expected_section = "dictionary"
self.assertEqual(table_meta.get("section"), expected_section) self.assertEqual(table_meta.get("section"), expected_section)
admin_users_cols = {col["name"] for col in (by_table["admin_users"].get("columns") or [])} admin_users_cols = {col["name"] for col in (by_table["admin_users"].get("columns") or [])}
@ -201,6 +215,55 @@ class AdminUniversalCrudTests(unittest.TestCase):
forbidden = self.client.get("/api/admin/crud/meta/tables", headers=lawyer_headers) forbidden = self.client.get("/api/admin/crud/meta/tables", headers=lawyer_headers)
self.assertEqual(forbidden.status_code, 403) self.assertEqual(forbidden.status_code, 403)
def test_admin_can_toggle_dictionary_table_visibility(self):
admin_headers = self._auth_headers("ADMIN")
available = self.client.get("/api/admin/crud/meta/available-tables", headers=admin_headers)
self.assertEqual(available.status_code, 200)
rows = available.json().get("rows") or []
by_table = {row["table"]: row for row in rows}
self.assertIn("clients", by_table)
self.assertIn("table_availability", by_table)
self.assertEqual(by_table["table_availability"]["section"], "system")
self.assertTrue(bool(by_table["clients"]["is_active"]))
deactivated = self.client.patch(
"/api/admin/crud/meta/available-tables/clients",
headers=admin_headers,
json={"is_active": False},
)
self.assertEqual(deactivated.status_code, 200)
self.assertFalse(bool(deactivated.json().get("is_active")))
filtered_catalog = self.client.get("/api/admin/crud/meta/tables", headers=admin_headers)
self.assertEqual(filtered_catalog.status_code, 200)
filtered_tables = {row["table"] for row in (filtered_catalog.json().get("tables") or [])}
self.assertNotIn("clients", filtered_tables)
self.assertIn("requests", filtered_tables)
self.assertIn("invoices", filtered_tables)
activated = self.client.patch(
"/api/admin/crud/meta/available-tables/clients",
headers=admin_headers,
json={"is_active": True},
)
self.assertEqual(activated.status_code, 200)
self.assertTrue(bool(activated.json().get("is_active")))
refreshed_catalog = self.client.get("/api/admin/crud/meta/tables", headers=admin_headers)
self.assertEqual(refreshed_catalog.status_code, 200)
refreshed_tables = {row["table"] for row in (refreshed_catalog.json().get("tables") or [])}
self.assertIn("clients", refreshed_tables)
lawyer_headers = self._auth_headers("LAWYER")
forbidden_list = self.client.get("/api/admin/crud/meta/available-tables", headers=lawyer_headers)
self.assertEqual(forbidden_list.status_code, 403)
forbidden_patch = self.client.patch(
"/api/admin/crud/meta/available-tables/clients",
headers=lawyer_headers,
json={"is_active": False},
)
self.assertEqual(forbidden_patch.status_code, 403)
def test_lawyer_permissions_and_request_crud(self): def test_lawyer_permissions_and_request_crud(self):
lawyer_headers = self._auth_headers("LAWYER") lawyer_headers = self._auth_headers("LAWYER")
@ -347,6 +410,139 @@ class AdminUniversalCrudTests(unittest.TestCase):
self.assertIsNotNone(refreshed) self.assertIsNotNone(refreshed)
self.assertEqual(refreshed.status_code, "CLOSED") self.assertEqual(refreshed.status_code, "CLOSED")
def test_lawyer_messages_and_attachments_are_scoped_by_request_access(self):
with self.SessionLocal() as db:
lawyer_self = AdminUser(
role="LAWYER",
name="Юрист Свой",
email="lawyer.msg.self@example.com",
password_hash="hash",
is_active=True,
)
lawyer_other = AdminUser(
role="LAWYER",
name="Юрист Чужой",
email="lawyer.msg.other@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([lawyer_self, lawyer_other])
db.flush()
self_id = str(lawyer_self.id)
other_id = str(lawyer_other.id)
own = Request(
track_number="TRK-MSG-OWN",
client_name="Клиент Свой",
client_phone="+79990010101",
status_code="IN_PROGRESS",
description="own",
extra_fields={},
assigned_lawyer_id=self_id,
)
foreign = Request(
track_number="TRK-MSG-FOREIGN",
client_name="Клиент Чужой",
client_phone="+79990010102",
status_code="IN_PROGRESS",
description="foreign",
extra_fields={},
assigned_lawyer_id=other_id,
)
unassigned = Request(
track_number="TRK-MSG-UNASSIGNED",
client_name="Клиент Без назначения",
client_phone="+79990010103",
status_code="NEW",
description="unassigned",
extra_fields={},
assigned_lawyer_id=None,
)
db.add_all([own, foreign, unassigned])
db.flush()
msg_own = Message(request_id=own.id, author_type="CLIENT", author_name="Клиент", body="own", immutable=False)
msg_foreign = Message(request_id=foreign.id, author_type="CLIENT", author_name="Клиент", body="foreign", immutable=False)
msg_unassigned = Message(request_id=unassigned.id, author_type="CLIENT", author_name="Клиент", body="unassigned", immutable=False)
db.add_all([msg_own, msg_foreign, msg_unassigned])
db.flush()
att_own = Attachment(
request_id=own.id,
message_id=msg_own.id,
file_name="own.pdf",
mime_type="application/pdf",
size_bytes=100,
s3_key=f"requests/{own.id}/own.pdf",
immutable=False,
)
att_foreign = Attachment(
request_id=foreign.id,
message_id=msg_foreign.id,
file_name="foreign.pdf",
mime_type="application/pdf",
size_bytes=100,
s3_key=f"requests/{foreign.id}/foreign.pdf",
immutable=False,
)
att_unassigned = Attachment(
request_id=unassigned.id,
message_id=msg_unassigned.id,
file_name="unassigned.pdf",
mime_type="application/pdf",
size_bytes=100,
s3_key=f"requests/{unassigned.id}/unassigned.pdf",
immutable=False,
)
db.add_all([att_own, att_foreign, att_unassigned])
db.commit()
own_id = str(own.id)
unassigned_id = str(unassigned.id)
foreign_msg_id = str(msg_foreign.id)
foreign_att_id = str(att_foreign.id)
headers = self._auth_headers("LAWYER", email="lawyer.msg.self@example.com", sub=self_id)
messages_query = self.client.post(
"/api/admin/crud/messages/query",
headers=headers,
json={"filters": [], "sort": [{"field": "created_at", "dir": "asc"}], "page": {"limit": 50, "offset": 0}},
)
self.assertEqual(messages_query.status_code, 200)
message_request_ids = {str(row.get("request_id")) for row in (messages_query.json().get("rows") or [])}
self.assertEqual(message_request_ids, {own_id, unassigned_id})
attachments_query = self.client.post(
"/api/admin/crud/attachments/query",
headers=headers,
json={"filters": [], "sort": [{"field": "created_at", "dir": "asc"}], "page": {"limit": 50, "offset": 0}},
)
self.assertEqual(attachments_query.status_code, 200)
attachment_request_ids = {str(row.get("request_id")) for row in (attachments_query.json().get("rows") or [])}
self.assertEqual(attachment_request_ids, {own_id, unassigned_id})
foreign_message_get = self.client.get(f"/api/admin/crud/messages/{foreign_msg_id}", headers=headers)
self.assertEqual(foreign_message_get.status_code, 403)
foreign_attachment_get = self.client.get(f"/api/admin/crud/attachments/{foreign_att_id}", headers=headers)
self.assertEqual(foreign_attachment_get.status_code, 403)
created_message = self.client.post(
"/api/admin/crud/messages",
headers=headers,
json={"request_id": own_id, "body": "Ответ юриста"},
)
self.assertEqual(created_message.status_code, 201)
self.assertEqual(created_message.json().get("author_type"), "LAWYER")
self.assertEqual(created_message.json().get("request_id"), own_id)
blocked_unassigned_create = self.client.post(
"/api/admin/crud/messages",
headers=headers,
json={"request_id": unassigned_id, "body": "Попытка без назначения"},
)
self.assertEqual(blocked_unassigned_create.status_code, 403)
def test_topic_status_flow_supports_branching_transitions(self): def test_topic_status_flow_supports_branching_transitions(self):
headers = self._auth_headers("ADMIN", email="root@example.com") headers = self._auth_headers("ADMIN", email="root@example.com")
with self.SessionLocal() as db: with self.SessionLocal() as db:
@ -394,6 +590,95 @@ class AdminUniversalCrudTests(unittest.TestCase):
) )
self.assertEqual(second_branch.status_code, 200) self.assertEqual(second_branch.status_code, 200)
def test_admin_chat_service_endpoints_follow_rbac(self):
with self.SessionLocal() as db:
lawyer_self = AdminUser(
role="LAWYER",
name="Юрист Чат Свой",
email="lawyer.chat.self@example.com",
password_hash="hash",
is_active=True,
)
lawyer_other = AdminUser(
role="LAWYER",
name="Юрист Чат Чужой",
email="lawyer.chat.other@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([lawyer_self, lawyer_other])
db.flush()
self_id = str(lawyer_self.id)
other_id = str(lawyer_other.id)
own = Request(
track_number="TRK-CHAT-ADMIN-OWN",
client_name="Клиент Свой",
client_phone="+79990030001",
status_code="IN_PROGRESS",
description="own",
extra_fields={},
assigned_lawyer_id=self_id,
)
foreign = Request(
track_number="TRK-CHAT-ADMIN-FOREIGN",
client_name="Клиент Чужой",
client_phone="+79990030002",
status_code="IN_PROGRESS",
description="foreign",
extra_fields={},
assigned_lawyer_id=other_id,
)
unassigned = Request(
track_number="TRK-CHAT-ADMIN-UNASSIGNED",
client_name="Клиент Без назначения",
client_phone="+79990030003",
status_code="NEW",
description="unassigned",
extra_fields={},
assigned_lawyer_id=None,
)
db.add_all([own, foreign, unassigned])
db.flush()
db.add(Message(request_id=own.id, author_type="CLIENT", author_name="Клиент", body="start"))
db.commit()
own_id = str(own.id)
foreign_id = str(foreign.id)
unassigned_id = str(unassigned.id)
lawyer_headers = self._auth_headers("LAWYER", email="lawyer.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)
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)
self.assertEqual(foreign_list.status_code, 403)
own_create = self.client.post(
f"/api/admin/chat/requests/{own_id}/messages",
headers=lawyer_headers,
json={"body": "Ответ из chat service"},
)
self.assertEqual(own_create.status_code, 201)
self.assertEqual(own_create.json()["author_type"], "LAWYER")
unassigned_create = self.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(
f"/api/admin/chat/requests/{foreign_id}/messages",
headers=admin_headers,
json={"body": "Сообщение администратора"},
)
self.assertEqual(admin_create.status_code, 201)
self.assertEqual(admin_create.json()["author_type"], "SYSTEM")
def test_request_read_markers_status_update_and_lawyer_open_reset(self): def test_request_read_markers_status_update_and_lawyer_open_reset(self):
with self.SessionLocal() as db: with self.SessionLocal() as db:
lawyer = AdminUser( lawyer = AdminUser(
@ -688,6 +973,100 @@ class AdminUniversalCrudTests(unittest.TestCase):
self.assertEqual(history[0].from_status, "NEW") self.assertEqual(history[0].from_status, "NEW")
self.assertEqual(history[0].to_status, "IN_PROGRESS") self.assertEqual(history[0].to_status, "IN_PROGRESS")
def test_request_status_route_returns_progress_and_respects_role_scope(self):
with self.SessionLocal() as db:
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=1, kind="DEFAULT"),
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=2, kind="DEFAULT"),
Status(code="WAITING_CLIENT", name="Ожидание клиента", enabled=True, sort_order=3, kind="DEFAULT"),
]
)
db.add_all(
[
TopicStatusTransition(
topic_code="civil-law",
from_status="NEW",
to_status="IN_PROGRESS",
enabled=True,
sla_hours=24,
sort_order=1,
),
TopicStatusTransition(
topic_code="civil-law",
from_status="IN_PROGRESS",
to_status="WAITING_CLIENT",
enabled=True,
sla_hours=72,
sort_order=2,
),
]
)
lawyer = AdminUser(
role="LAWYER",
name="Юрист маршрута",
email="lawyer.route@example.com",
password_hash="hash",
is_active=True,
)
outsider = AdminUser(
role="LAWYER",
name="Чужой юрист",
email="lawyer.outside.route@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([lawyer, outsider])
db.flush()
req = Request(
track_number="TRK-ROUTE-1",
client_name="Клиент",
client_phone="+79990001122",
topic_code="civil-law",
status_code="IN_PROGRESS",
assigned_lawyer_id=str(lawyer.id),
description="route check",
extra_fields={},
)
db.add(req)
db.flush()
db.add(
StatusHistory(
request_id=req.id,
from_status="NEW",
to_status="IN_PROGRESS",
comment="start progress",
changed_by_admin_id=None,
)
)
db.commit()
request_id = str(req.id)
lawyer_id = str(lawyer.id)
outsider_id = str(outsider.id)
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
assigned_headers = self._auth_headers("LAWYER", email="lawyer.route@example.com", sub=lawyer_id)
outsider_headers = self._auth_headers("LAWYER", email="lawyer.outside.route@example.com", sub=outsider_id)
admin_response = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=admin_headers)
self.assertEqual(admin_response.status_code, 200)
payload = admin_response.json()
self.assertEqual(payload["current_status"], "IN_PROGRESS")
nodes = payload.get("nodes") or []
self.assertEqual([item["code"] for item in nodes], ["NEW", "IN_PROGRESS", "WAITING_CLIENT"])
self.assertEqual(nodes[0]["state"], "completed")
self.assertEqual(nodes[1]["state"], "current")
self.assertEqual(nodes[2]["state"], "pending")
self.assertEqual(nodes[1]["sla_hours"], 24)
self.assertEqual(nodes[2]["sla_hours"], 72)
assigned_response = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=assigned_headers)
self.assertEqual(assigned_response.status_code, 200)
self.assertEqual(assigned_response.json()["current_status"], "IN_PROGRESS")
outsider_forbidden = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=outsider_headers)
self.assertEqual(outsider_forbidden.status_code, 403)
def test_lawyer_can_claim_unassigned_request_and_takeover_is_forbidden(self): def test_lawyer_can_claim_unassigned_request_and_takeover_is_forbidden(self):
with self.SessionLocal() as db: with self.SessionLocal() as db:
lawyer1 = AdminUser( lawyer1 = AdminUser(
@ -936,6 +1315,64 @@ class AdminUniversalCrudTests(unittest.TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertIn("Неизвестные поля", response.json().get("detail", "")) self.assertIn("Неизвестные поля", response.json().get("detail", ""))
def test_calculated_fields_are_read_only_for_universal_crud(self):
headers = self._auth_headers("ADMIN", email="root@example.com")
blocked_create = self.client.post(
"/api/admin/crud/requests",
headers=headers,
json={
"client_name": "Клиент readonly",
"client_phone": "+79995550011",
"status_code": "NEW",
"description": "calc readonly",
"invoice_amount": 12500,
},
)
self.assertEqual(blocked_create.status_code, 400)
self.assertIn("Неизвестные поля", blocked_create.json().get("detail", ""))
created = self.client.post(
"/api/admin/crud/requests",
headers=headers,
json={
"client_name": "Клиент readonly",
"client_phone": "+79995550012",
"status_code": "NEW",
"description": "valid create",
},
)
self.assertEqual(created.status_code, 201)
request_id = created.json()["id"]
blocked_patch = self.client.patch(
f"/api/admin/crud/requests/{request_id}",
headers=headers,
json={"paid_at": "2026-02-24T12:00:00+03:00"},
)
self.assertEqual(blocked_patch.status_code, 400)
self.assertIn("Неизвестные поля", blocked_patch.json().get("detail", ""))
meta_response = self.client.get("/api/admin/crud/meta/tables", headers=headers)
self.assertEqual(meta_response.status_code, 200)
by_table = {row["table"]: row for row in (meta_response.json().get("tables") or [])}
request_columns = {col["name"]: col for col in (by_table.get("requests", {}).get("columns") or [])}
self.assertIn("invoice_amount", request_columns)
self.assertIn("paid_at", request_columns)
self.assertIn("paid_by_admin_id", request_columns)
self.assertIn("total_attachments_bytes", request_columns)
self.assertFalse(request_columns["invoice_amount"]["editable"])
self.assertFalse(request_columns["paid_at"]["editable"])
self.assertFalse(request_columns["paid_by_admin_id"]["editable"])
self.assertFalse(request_columns["total_attachments_bytes"]["editable"])
invoice_columns = {col["name"]: col for col in (by_table.get("invoices", {}).get("columns") or [])}
self.assertIn("issued_at", invoice_columns)
self.assertIn("paid_at", invoice_columns)
self.assertFalse(invoice_columns["issued_at"]["editable"])
self.assertFalse(invoice_columns["paid_at"]["editable"])
def test_topic_code_is_autogenerated_when_missing(self): def test_topic_code_is_autogenerated_when_missing(self):
headers = self._auth_headers("ADMIN") headers = self._auth_headers("ADMIN")
first = self.client.post( first = self.client.post(

View file

@ -80,6 +80,8 @@ class MigrationTests(unittest.TestCase):
def test_upgrade_head_creates_expected_tables(self): def test_upgrade_head_creates_expected_tables(self):
expected = { expected = {
"admin_users", "admin_users",
"clients",
"table_availability",
"topics", "topics",
"statuses", "statuses",
"form_fields", "form_fields",
@ -106,11 +108,13 @@ class MigrationTests(unittest.TestCase):
def test_alembic_version_is_set(self): def test_alembic_version_is_set(self):
with self.engine.connect() as conn: with self.engine.connect() as conn:
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one() version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
self.assertEqual(version, "0014_security_audit_log") self.assertEqual(version, "0016_table_availability")
def test_responsible_column_exists_in_all_domain_tables(self): def test_responsible_column_exists_in_all_domain_tables(self):
tables = { tables = {
"admin_users", "admin_users",
"clients",
"table_availability",
"topics", "topics",
"statuses", "statuses",
"form_fields", "form_fields",
@ -171,6 +175,7 @@ class MigrationTests(unittest.TestCase):
def test_requests_contains_financial_columns(self): def test_requests_contains_financial_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("requests")} columns = {column["name"] for column in self.inspector.get_columns("requests")}
self.assertIn("client_id", columns)
self.assertIn("effective_rate", columns) self.assertIn("effective_rate", columns)
self.assertIn("invoice_amount", columns) self.assertIn("invoice_amount", columns)
self.assertIn("paid_at", columns) self.assertIn("paid_at", columns)
@ -178,6 +183,7 @@ class MigrationTests(unittest.TestCase):
def test_invoices_contains_core_columns(self): def test_invoices_contains_core_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("invoices")} columns = {column["name"] for column in self.inspector.get_columns("invoices")}
self.assertIn("client_id", columns)
self.assertIn("request_id", columns) self.assertIn("request_id", columns)
self.assertIn("invoice_number", columns) self.assertIn("invoice_number", columns)
self.assertIn("status", columns) self.assertIn("status", columns)
@ -194,3 +200,11 @@ class MigrationTests(unittest.TestCase):
columns = {column["name"] for column in self.inspector.get_columns("statuses")} columns = {column["name"] for column in self.inspector.get_columns("statuses")}
self.assertIn("kind", columns) self.assertIn("kind", columns)
self.assertIn("invoice_template", columns) self.assertIn("invoice_template", columns)
def test_clients_contains_core_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("clients")}
self.assertIn("id", columns)
self.assertIn("full_name", columns)
self.assertIn("phone", columns)
self.assertIn("created_at", columns)
self.assertIn("responsible", columns)

View file

@ -200,6 +200,37 @@ class PublicCabinetTests(unittest.TestCase):
self.assertTrue(req.lawyer_has_unread_updates) self.assertTrue(req.lawyer_has_unread_updates)
self.assertEqual(req.lawyer_unread_event_type, "MESSAGE") self.assertEqual(req.lawyer_unread_event_type, "MESSAGE")
def test_public_chat_service_endpoints_work_for_authorized_client(self):
with self.SessionLocal() as db:
req = Request(
track_number="TRK-CHAT-001",
client_name="Клиент Чат",
client_phone="+79997770000",
topic_code="consulting",
status_code="NEW",
description="Проверка chat service",
extra_fields={},
)
db.add(req)
db.commit()
cookies = self._public_cookies("TRK-CHAT-001")
created = self.client.post(
"/api/public/chat/requests/TRK-CHAT-001/messages",
cookies=cookies,
json={"body": "Сообщение через выделенный сервис"},
)
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)
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"))
self.assertEqual(denied.status_code, 403)
def test_public_cabinet_respects_track_access(self): def test_public_cabinet_respects_track_access(self):
with self.SessionLocal() as db: with self.SessionLocal() as db:
req = Request( req = Request(

View file

@ -21,6 +21,7 @@ from app.main import app
from app.core.config import settings from app.core.config import settings
from app.core.security import create_jwt, decode_jwt from app.core.security import create_jwt, decode_jwt
from app.db.session import get_db from app.db.session import get_db
from app.models.client import Client
from app.models.notification import Notification from app.models.notification import Notification
from app.models.otp_session import OtpSession from app.models.otp_session import OtpSession
from app.models.request import Request from app.models.request import Request
@ -36,6 +37,7 @@ class PublicRequestCreateTests(unittest.TestCase):
poolclass=StaticPool, poolclass=StaticPool,
) )
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False) cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
Client.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine) Request.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine) Notification.__table__.create(bind=cls.engine)
OtpSession.__table__.create(bind=cls.engine) OtpSession.__table__.create(bind=cls.engine)
@ -47,6 +49,7 @@ class PublicRequestCreateTests(unittest.TestCase):
OtpSession.__table__.drop(bind=cls.engine) OtpSession.__table__.drop(bind=cls.engine)
TopicRequiredField.__table__.drop(bind=cls.engine) TopicRequiredField.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine) Request.__table__.drop(bind=cls.engine)
Client.__table__.drop(bind=cls.engine)
cls.engine.dispose() cls.engine.dispose()
def setUp(self): def setUp(self):
@ -55,6 +58,7 @@ class PublicRequestCreateTests(unittest.TestCase):
db.execute(delete(OtpSession)) db.execute(delete(OtpSession))
db.execute(delete(TopicRequiredField)) db.execute(delete(TopicRequiredField))
db.execute(delete(Request)) db.execute(delete(Request))
db.execute(delete(Client))
db.commit() db.commit()
def override_get_db(): def override_get_db():
@ -114,12 +118,16 @@ class PublicRequestCreateTests(unittest.TestCase):
self.assertIsNotNone(created) self.assertIsNotNone(created)
self.assertEqual(created.client_name, payload["client_name"]) self.assertEqual(created.client_name, payload["client_name"])
self.assertEqual(created.client_phone, payload["client_phone"]) self.assertEqual(created.client_phone, payload["client_phone"])
self.assertIsNotNone(created.client_id)
self.assertEqual(created.topic_code, payload["topic_code"]) self.assertEqual(created.topic_code, payload["topic_code"])
self.assertEqual(created.description, payload["description"]) self.assertEqual(created.description, payload["description"])
self.assertEqual(created.extra_fields, payload["extra_fields"]) self.assertEqual(created.extra_fields, payload["extra_fields"])
self.assertEqual(created.status_code, "NEW") self.assertEqual(created.status_code, "NEW")
self.assertEqual(created.track_number, body["track_number"]) self.assertEqual(created.track_number, body["track_number"])
self.assertEqual(created.responsible, "Клиент") self.assertEqual(created.responsible, "Клиент")
client = db.get(Client, created.client_id)
self.assertIsNotNone(client)
self.assertEqual(client.phone, payload["client_phone"])
# After creation, cookie is switched to VIEW_REQUEST for this track. # After creation, cookie is switched to VIEW_REQUEST for this track.
read = self.client.get(f"/api/public/requests/{body['track_number']}") read = self.client.get(f"/api/public/requests/{body['track_number']}")
@ -170,6 +178,73 @@ class PublicRequestCreateTests(unittest.TestCase):
denied_other_track = self.client.get("/api/public/requests/TRK-OTHER") denied_other_track = self.client.get("/api/public/requests/TRK-OTHER")
self.assertEqual(denied_other_track.status_code, 403) self.assertEqual(denied_other_track.status_code, 403)
def test_view_request_can_use_phone_otp_and_switch_between_client_requests(self):
phone = "+79996660077"
with self.SessionLocal() as db:
client = Client(full_name="Клиент Мульти", phone=phone, responsible="seed")
db.add(client)
db.flush()
db.add_all(
[
Request(
track_number="TRK-MULTI-1",
client_id=client.id,
client_name=client.full_name,
client_phone=client.phone,
topic_code="consulting",
status_code="NEW",
description="Первая",
extra_fields={},
),
Request(
track_number="TRK-MULTI-2",
client_id=client.id,
client_name=client.full_name,
client_phone=client.phone,
topic_code="consulting",
status_code="IN_PROGRESS",
description="Вторая",
extra_fields={},
),
Request(
track_number="TRK-FOREIGN-1",
client_name="Другой клиент",
client_phone="+79990009999",
topic_code="consulting",
status_code="NEW",
description="Чужая",
extra_fields={},
),
]
)
db.commit()
with patch("app.api.public.otp._generate_code", return_value="111111"):
sent = self.client.post(
"/api/public/otp/send",
json={"purpose": "VIEW_REQUEST", "client_phone": phone},
)
self.assertEqual(sent.status_code, 200)
verified = self.client.post(
"/api/public/otp/verify",
json={"purpose": "VIEW_REQUEST", "client_phone": phone, "code": "111111"},
)
self.assertEqual(verified.status_code, 200)
list_resp = self.client.get("/api/public/requests/my")
self.assertEqual(list_resp.status_code, 200)
rows = list_resp.json().get("rows") or []
tracks = {row["track_number"] for row in rows}
self.assertEqual(tracks, {"TRK-MULTI-1", "TRK-MULTI-2"})
opened = self.client.get("/api/public/requests/TRK-MULTI-2")
self.assertEqual(opened.status_code, 200)
self.assertEqual(opened.json()["track_number"], "TRK-MULTI-2")
denied = self.client.get("/api/public/requests/TRK-FOREIGN-1")
self.assertEqual(denied.status_code, 403)
def test_open_request_marks_client_updates_as_read(self): def test_open_request_marks_client_updates_as_read(self):
with self.SessionLocal() as db: with self.SessionLocal() as db:
row = Request( row = Request(
@ -274,3 +349,38 @@ class PublicRequestCreateTests(unittest.TestCase):
self.assertIn(f"{settings.PUBLIC_COOKIE_NAME}=", cookie_header) self.assertIn(f"{settings.PUBLIC_COOKIE_NAME}=", cookie_header)
self.assertIn(f"Max-Age={settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600}", cookie_header) self.assertIn(f"Max-Age={settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600}", cookie_header)
self.assertIn("httponly", cookie_header.lower()) self.assertIn("httponly", cookie_header.lower())
def test_verify_view_otp_by_phone_sets_view_session_subject_as_phone(self):
phone = "+79998887766"
with self.SessionLocal() as db:
db.add(
Request(
track_number="TRK-VIEW-PHONE-1",
client_name="Телефонный клиент",
client_phone=phone,
topic_code="consulting",
status_code="NEW",
description="Проверка",
extra_fields={},
)
)
db.commit()
with patch("app.api.public.otp._generate_code", return_value="222222"):
sent = self.client.post(
"/api/public/otp/send",
json={"purpose": "VIEW_REQUEST", "client_phone": phone},
)
self.assertEqual(sent.status_code, 200)
verified = self.client.post(
"/api/public/otp/verify",
json={"purpose": "VIEW_REQUEST", "client_phone": phone, "code": "222222"},
)
self.assertEqual(verified.status_code, 200)
token = verified.cookies.get(settings.PUBLIC_COOKIE_NAME)
self.assertTrue(token)
payload = decode_jwt(token, settings.PUBLIC_JWT_SECRET)
self.assertEqual(payload.get("sub"), phone)
self.assertEqual(payload.get("purpose"), "VIEW_REQUEST")

View file

@ -22,6 +22,7 @@ from app.main import app
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.admin_user_topic import AdminUserTopic from app.models.admin_user_topic import AdminUserTopic
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.client import Client
from app.models.notification import Notification from app.models.notification import Notification
from app.models.request import Request from app.models.request import Request
from app.models.status import Status from app.models.status import Status
@ -40,6 +41,7 @@ class RequestRatesTests(unittest.TestCase):
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False) cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine) AdminUser.__table__.create(bind=cls.engine)
AdminUserTopic.__table__.create(bind=cls.engine) AdminUserTopic.__table__.create(bind=cls.engine)
Client.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine) Request.__table__.create(bind=cls.engine)
Status.__table__.create(bind=cls.engine) Status.__table__.create(bind=cls.engine)
TopicRequiredField.__table__.create(bind=cls.engine) TopicRequiredField.__table__.create(bind=cls.engine)
@ -57,6 +59,7 @@ class RequestRatesTests(unittest.TestCase):
TopicRequiredField.__table__.drop(bind=cls.engine) TopicRequiredField.__table__.drop(bind=cls.engine)
Status.__table__.drop(bind=cls.engine) Status.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine) Request.__table__.drop(bind=cls.engine)
Client.__table__.drop(bind=cls.engine)
AdminUserTopic.__table__.drop(bind=cls.engine) AdminUserTopic.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine) AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose() cls.engine.dispose()
@ -68,6 +71,7 @@ class RequestRatesTests(unittest.TestCase):
db.execute(delete(TopicRequiredField)) db.execute(delete(TopicRequiredField))
db.execute(delete(Status)) db.execute(delete(Status))
db.execute(delete(Request)) db.execute(delete(Request))
db.execute(delete(Client))
db.execute(delete(AdminUserTopic)) db.execute(delete(AdminUserTopic))
db.execute(delete(AdminUser)) db.execute(delete(AdminUser))
db.commit() db.commit()

View file

@ -232,6 +232,44 @@ class UploadsS3Tests(unittest.TestCase):
self.assertEqual(len(rows), 1) self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].s3_key, key) self.assertEqual(rows[0].s3_key, key)
def test_public_attachment_object_preview_returns_inline_response(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
req = Request(
track_number="TRK-PUB-PREVIEW",
client_name="Клиент",
client_phone="+79994443322",
topic_code="civil-law",
status_code="IN_PROGRESS",
extra_fields={},
)
db.add(req)
db.flush()
key = f"requests/{req.id}/preview.pdf"
attachment = Attachment(
request_id=req.id,
file_name="preview.pdf",
mime_type="application/pdf",
size_bytes=1280,
s3_key=key,
)
db.add(attachment)
db.commit()
attachment_id = str(attachment.id)
track = req.track_number
fake_s3.objects[key] = {"size": 1280, "mime": "application/pdf", "content": b"pdf-preview"}
public_token = create_jwt({"sub": track, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3):
response = self.client.get(f"/api/public/uploads/object/{attachment_id}", cookies=cookies)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b"pdf-preview")
self.assertIn("application/pdf", response.headers.get("content-type", ""))
self.assertIn("inline;", response.headers.get("content-disposition", ""))
def test_admin_request_attachment_upload_sets_client_unread_marker(self): def test_admin_request_attachment_upload_sets_client_unread_marker(self):
fake_s3 = _FakeS3Storage() fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db: with self.SessionLocal() as db: