feat: add support tickets and imrpove web app loading
This commit is contained in:
@@ -33,6 +33,7 @@ tmp/
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__/
|
||||
|
||||
@@ -304,13 +304,24 @@ LOG_LEVEL=INFO #
|
||||
# Admin Logging Configuration
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_SUPPORT_THREAD_ID= # Optional: Thread ID for support ticket messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_SUPPORT=True # Log support tickets and replies
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
LOG_ADMIN_ACTIONS=True # Log actions from users listed in ADMIN_IDS
|
||||
|
||||
# Support tickets
|
||||
SUPPORT_TICKETS_ENABLED=True # Enable support tickets in the Mini App
|
||||
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=False # Send support ticket emails to admins with email addresses
|
||||
SUPPORT_TICKET_MAX_BODY_LENGTH=4000 # Max message length
|
||||
SUPPORT_TICKET_MAX_SUBJECT_LENGTH=160 # Max subject length
|
||||
SUPPORT_TICKET_RATE_LIMIT_PER_HOUR=5 # New tickets per user per hour
|
||||
SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS=300 # Min seconds between admin Telegram/log notifications per unread ticket
|
||||
SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS=1800 # Min seconds between admin email notifications per unread ticket
|
||||
|
||||
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
|
||||
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
|
||||
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
|
||||
|
||||
@@ -7,6 +7,7 @@ bot_database.sqlite3
|
||||
!.env.example
|
||||
docker-compose-dev.yml
|
||||
scratch_*.py
|
||||
scratch/
|
||||
*.local.*
|
||||
node_modules/
|
||||
.git/
|
||||
@@ -15,9 +16,11 @@ node_modules/
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
bot/app/web/templates/subscription_webapp.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css
|
||||
tmp
|
||||
.claude
|
||||
|
||||
|
||||
@@ -7,12 +7,15 @@ from bot.payment_providers import (
|
||||
build_provider_configs,
|
||||
build_provider_services,
|
||||
)
|
||||
from bot.services.email_auth_service import EmailAuthService
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.support_service import SupportService
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
@@ -27,6 +30,23 @@ def build_core_services(
|
||||
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
|
||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
|
||||
email_auth_service = EmailAuthService(settings)
|
||||
notification_service = NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
session_factory=async_session_factory,
|
||||
email_auth_service=email_auth_service,
|
||||
bot_username=bot_username_for_default_return,
|
||||
)
|
||||
support_service = SupportService(
|
||||
async_session_factory,
|
||||
settings,
|
||||
bot,
|
||||
i18n,
|
||||
notification_service,
|
||||
email_auth_service,
|
||||
)
|
||||
panel_webhook_service = PanelWebhookService(
|
||||
bot, settings, i18n, async_session_factory, panel_service
|
||||
)
|
||||
@@ -58,6 +78,9 @@ def build_core_services(
|
||||
"subscription_service": subscription_service,
|
||||
"referral_service": referral_service,
|
||||
"promo_code_service": promo_code_service,
|
||||
"notification_service": notification_service,
|
||||
"email_auth_service": email_auth_service,
|
||||
"support_service": support_service,
|
||||
"panel_webhook_service": panel_webhook_service,
|
||||
"lknpd_service": lknpd_service,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ from bot.app.web.admin_api_impl import (
|
||||
routes as _routes,
|
||||
settings as _settings,
|
||||
stats as _stats,
|
||||
support as _support,
|
||||
sync as _sync,
|
||||
tariffs as _tariffs,
|
||||
themes as _themes,
|
||||
@@ -30,6 +31,7 @@ _MODULES = (
|
||||
_payments,
|
||||
_promos,
|
||||
_logs,
|
||||
_support,
|
||||
_broadcast,
|
||||
_sync,
|
||||
_ads,
|
||||
|
||||
@@ -45,6 +45,16 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
|
||||
router.add_get("/api/admin/logs", admin_logs_route)
|
||||
|
||||
router.add_get("/api/admin/support/tickets", admin_support_tickets_route)
|
||||
router.add_get("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_detail_route)
|
||||
router.add_post(
|
||||
"/api/admin/support/tickets/{id:\\d+}/messages",
|
||||
admin_support_ticket_reply_route,
|
||||
)
|
||||
router.add_patch("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_patch_route)
|
||||
router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route)
|
||||
router.add_get("/api/admin/support/stats", admin_support_stats_route)
|
||||
|
||||
router.add_post("/api/admin/broadcast", admin_broadcast_route)
|
||||
router.add_post("/api/admin/sync", admin_sync_route)
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, constr, field_validator
|
||||
|
||||
from bot.services.support_service import TicketNotFound
|
||||
from db.dal import support_dal, user_dal
|
||||
from db.models import SupportTicket, SupportTicketMessage
|
||||
|
||||
|
||||
class AdminTicketReplyPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
body: constr(min_length=1, max_length=4000)
|
||||
is_internal_note: bool = False
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def _strip_body(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("empty_text")
|
||||
return stripped
|
||||
|
||||
|
||||
class AdminTicketPatchPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
status: Optional[Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]] = (
|
||||
None
|
||||
)
|
||||
priority: Optional[Literal["low", "normal", "high", "urgent"]] = None
|
||||
category: Optional[Literal["billing", "technical", "account", "other"]] = None
|
||||
assigned_admin_id: Optional[int] = None
|
||||
|
||||
|
||||
def _validate_model_payload(model_cls, payload: Dict[str, Any]):
|
||||
try:
|
||||
return model_cls.model_validate(payload), None
|
||||
except ValidationError:
|
||||
return None, _error(400, "invalid_request", "Invalid request")
|
||||
|
||||
|
||||
def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
|
||||
return {
|
||||
"ticket_id": ticket.ticket_id,
|
||||
"user_id": ticket.user_id,
|
||||
"subject": ticket.subject,
|
||||
"category": ticket.category,
|
||||
"priority": ticket.priority,
|
||||
"status": ticket.status,
|
||||
"assigned_admin_id": ticket.assigned_admin_id,
|
||||
"last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
|
||||
"last_message_role": ticket.last_message_role,
|
||||
"unread_user_count": int(ticket.unread_user_count or 0),
|
||||
"unread_admin_count": int(ticket.unread_admin_count or 0),
|
||||
"created_at": ticket.created_at.isoformat() if ticket.created_at else None,
|
||||
"updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
|
||||
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _user_display_name(user) -> Optional[str]:
|
||||
if not user:
|
||||
return None
|
||||
name = " ".join(
|
||||
part.strip() for part in [user.first_name, user.last_name] if part and part.strip()
|
||||
).strip()
|
||||
return name or user.username or user.email or str(user.user_id)
|
||||
|
||||
|
||||
def _support_message_payload(
|
||||
message: SupportTicketMessage,
|
||||
*,
|
||||
authors: Optional[Dict[int, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
author = authors.get(message.author_user_id) if authors and message.author_user_id else None
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
"ticket_id": message.ticket_id,
|
||||
"author_role": message.author_role,
|
||||
"author_user_id": message.author_user_id,
|
||||
"author_name": _user_display_name(author),
|
||||
"body": message.body,
|
||||
"is_internal_note": bool(message.is_internal_note),
|
||||
"created_at": message.created_at.isoformat() if message.created_at else None,
|
||||
"read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
|
||||
"read_by_admin_at": message.read_by_admin_at.isoformat()
|
||||
if message.read_by_admin_at
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def _admin_support_user_payload(user) -> Dict[str, Any]:
|
||||
if not user:
|
||||
return {}
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"telegram_id": user.telegram_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"email": user.email,
|
||||
"telegram_photo_url": user.telegram_photo_url,
|
||||
"is_banned": bool(user.is_banned),
|
||||
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
|
||||
}
|
||||
|
||||
|
||||
def _support_limit_offset(request: web.Request) -> tuple[int, int]:
|
||||
limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
|
||||
offset = max(0, int(request.query.get("offset", 0) or 0))
|
||||
return limit, offset
|
||||
|
||||
|
||||
async def admin_support_tickets_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
limit, offset = _support_limit_offset(request)
|
||||
assigned_raw = request.query.get("assigned")
|
||||
assigned_admin_id = None
|
||||
if assigned_raw and assigned_raw not in {"all", "any"}:
|
||||
assigned_admin_id = int(assigned_raw)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
tickets = await support_dal.list_admin_tickets(
|
||||
session,
|
||||
status=request.query.get("status") or None,
|
||||
priority=request.query.get("priority") or None,
|
||||
category=request.query.get("category") or None,
|
||||
assigned_admin_id=assigned_admin_id,
|
||||
search=request.query.get("search") or None,
|
||||
sort=request.query.get("sort") or "updated_desc",
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tickets": [
|
||||
{
|
||||
**_support_ticket_payload(ticket),
|
||||
"user": _admin_support_user_payload(getattr(ticket, "user", None)),
|
||||
}
|
||||
for ticket in tickets
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_support_ticket_detail_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
service = request.app["support_service"]
|
||||
async with async_session_factory() as session:
|
||||
ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=True)
|
||||
if not ticket:
|
||||
return _error(404, "not_found", "Ticket not found")
|
||||
user = await user_dal.get_user_by_id(session, ticket.user_id)
|
||||
snapshot = await service.build_user_snapshot(user, session=session) if user else {}
|
||||
author_ids = {m.author_user_id for m in messages if m.author_user_id is not None}
|
||||
authors = {}
|
||||
for author_id in author_ids:
|
||||
author = await user_dal.get_user_by_id(session, author_id)
|
||||
if author:
|
||||
authors[author_id] = author
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"ticket": {
|
||||
**_support_ticket_payload(ticket),
|
||||
"user": _admin_support_user_payload(user),
|
||||
},
|
||||
"messages": [_support_message_payload(m, authors=authors) for m in messages],
|
||||
"user_snapshot": snapshot,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_support_ticket_reply_route(request: web.Request) -> web.Response:
|
||||
admin_id = _require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
payload, error = _validate_model_payload(AdminTicketReplyPayload, await _read_json(request))
|
||||
if error:
|
||||
return error
|
||||
try:
|
||||
ticket, message = await request.app["support_service"].reply_as_admin(
|
||||
admin_id,
|
||||
ticket_id,
|
||||
payload.body,
|
||||
is_internal_note=payload.is_internal_note,
|
||||
)
|
||||
except TicketNotFound:
|
||||
return _error(404, "not_found", "Ticket not found")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
admin = await user_dal.get_user_by_id(session, admin_id)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"ticket": _support_ticket_payload(ticket),
|
||||
"message": _support_message_payload(
|
||||
message, authors={admin_id: admin} if admin else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_support_ticket_patch_route(request: web.Request) -> web.Response:
|
||||
admin_id = _require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
payload, error = _validate_model_payload(AdminTicketPatchPayload, await _read_json(request))
|
||||
if error:
|
||||
return error
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
try:
|
||||
if updates.get("status") == "closed":
|
||||
ticket = await request.app["support_service"].close_ticket(admin_id, ticket_id)
|
||||
updates.pop("status", None)
|
||||
if updates:
|
||||
ticket = await request.app["support_service"]._update_and_audit(
|
||||
admin_id,
|
||||
ticket_id,
|
||||
**updates,
|
||||
)
|
||||
else:
|
||||
ticket = await request.app["support_service"]._update_and_audit(
|
||||
admin_id,
|
||||
ticket_id,
|
||||
**updates,
|
||||
)
|
||||
except TicketNotFound:
|
||||
return _error(404, "not_found", "Ticket not found")
|
||||
return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
|
||||
|
||||
|
||||
async def admin_support_ticket_read_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
await request.app["support_service"].mark_read_as_admin(ticket_id)
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def admin_support_stats_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
stats = await support_dal.admin_stats(session)
|
||||
return web.json_response({"ok": True, "stats": stats})
|
||||
@@ -235,6 +235,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
),
|
||||
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
|
||||
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
|
||||
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
|
||||
SettingField(
|
||||
"LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"
|
||||
),
|
||||
@@ -248,8 +249,8 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"notifications",
|
||||
"Логировать действия администраторов",
|
||||
"Если выключено, события от пользователей из ADMIN_IDS не записываются в message logs.",
|
||||
i18n_label_key="settings_field_log_admin_actions_label",
|
||||
i18n_description_key="settings_field_log_admin_actions_description",
|
||||
i18n_label_key="admin_settings_field_log_admin_actions_label",
|
||||
i18n_description_key="admin_settings_field_log_admin_actions_description",
|
||||
),
|
||||
SettingField(
|
||||
"LOG_LEVEL",
|
||||
@@ -260,6 +261,73 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
),
|
||||
SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"),
|
||||
SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"),
|
||||
SettingField(
|
||||
"LOG_SUPPORT_THREAD_ID",
|
||||
"int",
|
||||
"notifications",
|
||||
"ID треда поддержки",
|
||||
"Тред лог-чата для уведомлений о тикетах поддержки.",
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKETS_ENABLED",
|
||||
"bool",
|
||||
"support",
|
||||
"Тикеты поддержки включены",
|
||||
"Показывает раздел поддержки в ЛК и включает создание тикетов.",
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED",
|
||||
"bool",
|
||||
"support",
|
||||
"Email-уведомления админам",
|
||||
(
|
||||
"Если выключено, новые тикеты и ответы пользователей останутся "
|
||||
"только в Telegram и лог-чате."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS",
|
||||
"int",
|
||||
"support",
|
||||
"Пауза Telegram-уведомлений",
|
||||
(
|
||||
"Минимум секунд между повторными Telegram/log уведомлениями "
|
||||
"по одному непрочитанному тикету."
|
||||
),
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS",
|
||||
"int",
|
||||
"support",
|
||||
"Пауза email-уведомлений",
|
||||
"Минимум секунд между повторными email-уведомлениями по одному непрочитанному тикету.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKET_MAX_BODY_LENGTH",
|
||||
"int",
|
||||
"support",
|
||||
"Макс. длина сообщения",
|
||||
"Максимальное количество символов в сообщении тикета.",
|
||||
min=1,
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKET_MAX_SUBJECT_LENGTH",
|
||||
"int",
|
||||
"support",
|
||||
"Макс. длина темы",
|
||||
"Максимальное количество символов в теме тикета.",
|
||||
min=1,
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKET_RATE_LIMIT_PER_HOUR",
|
||||
"int",
|
||||
"support",
|
||||
"Лимит тикетов в час",
|
||||
"Сколько новых тикетов пользователь может создать за час. 0 — без лимита.",
|
||||
min=0,
|
||||
),
|
||||
# ─── Devices ───────────────────────────────────────────────────
|
||||
SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"),
|
||||
SettingField(
|
||||
@@ -371,12 +439,15 @@ def manifest_payload() -> List[dict]:
|
||||
"trial": 5,
|
||||
"referral": 6,
|
||||
"notifications": 7,
|
||||
"devices": 8,
|
||||
"support": 8,
|
||||
"devices": 9,
|
||||
}
|
||||
items: List[dict] = []
|
||||
for field in aggregated_manifest():
|
||||
auto_label_i18n_key = f"settings_field_{field.key.lower()}_label"
|
||||
auto_description_i18n_key = f"settings_field_{field.key.lower()}_description"
|
||||
auto_label_i18n_key = f"admin_settings_field_{field.key.lower()}_label"
|
||||
auto_description_i18n_key = (
|
||||
f"admin_settings_field_{field.key.lower()}_description"
|
||||
)
|
||||
|
||||
default_value: Optional[str] = None
|
||||
owner = find_manifest_owner(field.key)
|
||||
|
||||
@@ -14,6 +14,7 @@ from bot.app.web.webapp import (
|
||||
payloads as _payloads,
|
||||
routes as _routes,
|
||||
serializers as _serializers,
|
||||
support as _support,
|
||||
)
|
||||
|
||||
_MODULES = (
|
||||
@@ -26,6 +27,7 @@ _MODULES = (
|
||||
_serializers,
|
||||
_billing,
|
||||
_devices,
|
||||
_support,
|
||||
_routes,
|
||||
_application,
|
||||
)
|
||||
|
||||
@@ -1,25 +1,60 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#03070b" />
|
||||
<link id="app-favicon" rel="icon" href="data:," sizes="any" />
|
||||
<title>/minishop</title>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css" />
|
||||
<style>
|
||||
.app-boot-fallback {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #03070b;
|
||||
}
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="theme-color" content="#03070b">
|
||||
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||
<title>/minishop</title>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css">
|
||||
</head>
|
||||
.app-boot-fallback__spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 2px solid rgba(242, 247, 244, 0.18);
|
||||
border-top-color: #00fe7a;
|
||||
border-radius: 999px;
|
||||
animation: appBootSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-boot-fallback__spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
<!-- WEBAPP_I18N_SCRIPT -->
|
||||
<!-- WEBAPP_CONFIG_SCRIPT -->
|
||||
<!-- WEBAPP_JS_SCRIPT -->
|
||||
<!-- WEBAPP_DEV_MOCK_START -->
|
||||
<script src="/subscription_webapp.js" defer></script>
|
||||
<!-- WEBAPP_DEV_MOCK_END -->
|
||||
</body>
|
||||
@keyframes appBootSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main id="app">
|
||||
<div class="app-boot-fallback" role="status" aria-label="Загрузка">
|
||||
<div class="app-boot-fallback__spinner" aria-hidden="true"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- WEBAPP_I18N_SCRIPT -->
|
||||
<!-- WEBAPP_CONFIG_SCRIPT -->
|
||||
<!-- WEBAPP_JS_SCRIPT -->
|
||||
<!-- WEBAPP_DEV_MOCK_START -->
|
||||
<script src="/subscription_webapp.js" defer></script>
|
||||
<!-- WEBAPP_DEV_MOCK_END -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -51,7 +51,7 @@ from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import parse_ip_entries, request_client_ip
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, security_dal, subscription_dal, user_dal
|
||||
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
|
||||
from db.dal.user_dal import UserMergeConflictError
|
||||
from db.models import Payment, User, UserTelegramAvatar
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ def create_subscription_webapp_application(
|
||||
"subscription_service",
|
||||
"promo_code_service",
|
||||
"referral_service",
|
||||
"support_service",
|
||||
"notification_service",
|
||||
"email_auth_service",
|
||||
"panel_service",
|
||||
*iter_service_keys(),
|
||||
):
|
||||
|
||||
@@ -15,7 +15,13 @@ async def health_route(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def css_asset_route(request: web.Request) -> web.Response:
|
||||
return await _serve_template_asset(request, "subscription_webapp.css", "text/css")
|
||||
asset_hash = request.match_info.get("asset_hash")
|
||||
filename = f"subscription_webapp.{asset_hash}.css" if asset_hash else "subscription_webapp.css"
|
||||
response = await _serve_template_asset(request, filename, "text/css")
|
||||
response.headers["Cache-Control"] = (
|
||||
"public, max-age=31536000, immutable" if asset_hash else "no-cache"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def _safe_theme_css_relative_path(raw_path: str) -> Optional[Path]:
|
||||
@@ -121,9 +127,7 @@ async def theme_asset_route(request: web.Request) -> web.Response:
|
||||
query = getattr(request, "query", {})
|
||||
response = web.Response(body=body, content_type=content_type)
|
||||
response.headers["Cache-Control"] = (
|
||||
"public, max-age=31536000, immutable"
|
||||
if query.get("v")
|
||||
else "public, max-age=3600"
|
||||
"public, max-age=31536000, immutable" if query.get("v") else "public, max-age=3600"
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -887,6 +891,41 @@ async def js_asset_route(request: web.Request) -> web.Response:
|
||||
return response
|
||||
|
||||
|
||||
WEBAPP_BOOTSTRAP_I18N_PREFIXES = ("wa_",)
|
||||
WEBAPP_BOOTSTRAP_I18N_KEYS = {"menu_support_button"}
|
||||
WEBAPP_I18N_SCOPES = {"webapp", "admin"}
|
||||
|
||||
|
||||
def _is_webapp_bootstrap_i18n_key(key: str) -> bool:
|
||||
return key in WEBAPP_BOOTSTRAP_I18N_KEYS or key.startswith(WEBAPP_BOOTSTRAP_I18N_PREFIXES)
|
||||
|
||||
|
||||
def _normalize_i18n_scope(raw_scope: object) -> str:
|
||||
scope = str(raw_scope or "webapp").strip().lower()
|
||||
return scope if scope in WEBAPP_I18N_SCOPES else "webapp"
|
||||
|
||||
|
||||
def _filter_webapp_i18n_payload(locales_data: object, scope: str = "webapp") -> Dict[str, Any]:
|
||||
if not isinstance(locales_data, dict):
|
||||
return {}
|
||||
|
||||
normalized_scope = _normalize_i18n_scope(scope)
|
||||
payload: Dict[str, Any] = {}
|
||||
for lang, messages in locales_data.items():
|
||||
if not isinstance(messages, dict):
|
||||
continue
|
||||
filtered: Dict[str, Any] = {}
|
||||
for key, value in messages.items():
|
||||
key_text = str(key)
|
||||
is_bootstrap_key = _is_webapp_bootstrap_i18n_key(key_text)
|
||||
if (normalized_scope == "webapp" and is_bootstrap_key) or (
|
||||
normalized_scope == "admin" and not is_bootstrap_key
|
||||
):
|
||||
filtered[key_text] = value
|
||||
payload[str(lang)] = filtered
|
||||
return payload
|
||||
|
||||
|
||||
def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
||||
settings: Settings = request.app["settings"]
|
||||
cached = _get_cached_webapp_settings(request)
|
||||
@@ -897,6 +936,8 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
||||
if preview_theme is None or not preview_theme.enabled:
|
||||
preview_key = ""
|
||||
i18n_instance: Optional[object] = request.app.get("i18n")
|
||||
i18n_scope = _normalize_i18n_scope(request.query.get("i18n_scope") or "webapp")
|
||||
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
|
||||
return {
|
||||
"config": {
|
||||
"title": settings.WEBAPP_TITLE,
|
||||
@@ -929,12 +970,29 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
||||
"appVersion": _resolve_app_version(),
|
||||
"appRepositoryUrl": APP_REPOSITORY_URL,
|
||||
},
|
||||
"i18n": getattr(i18n_instance, "locales_data", {}) if i18n_instance else {},
|
||||
"i18n": _filter_webapp_i18n_payload(locales_data, i18n_scope),
|
||||
}
|
||||
|
||||
|
||||
async def bootstrap_route(request: web.Request) -> web.Response:
|
||||
return web.json_response({"ok": True, **_build_webapp_bootstrap_payload(request)})
|
||||
response = web.json_response({"ok": True, **_build_webapp_bootstrap_payload(request)})
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
async def i18n_route(request: web.Request) -> web.Response:
|
||||
i18n_instance: Optional[object] = request.app.get("i18n")
|
||||
scope = _normalize_i18n_scope(request.query.get("scope") or "webapp")
|
||||
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
|
||||
response = web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"scope": scope,
|
||||
"i18n": _filter_webapp_i18n_payload(locales_data, scope),
|
||||
}
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
async def index_route(request: web.Request) -> web.Response:
|
||||
@@ -950,6 +1008,11 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
bootstrap = _build_webapp_bootstrap_payload(request)
|
||||
config = bootstrap["config"]
|
||||
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
|
||||
html = html.replace(
|
||||
'href="/subscription_webapp.css"',
|
||||
f'href="/{_resolve_webapp_css_asset_name()}"',
|
||||
1,
|
||||
)
|
||||
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
|
||||
if initial_theme_markup:
|
||||
html = html.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
|
||||
@@ -997,7 +1060,9 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
),
|
||||
1,
|
||||
)
|
||||
return web.Response(text=html, content_type="text/html", charset="utf-8")
|
||||
response = web.Response(text=html, content_type="text/html", charset="utf-8")
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
async def _serve_template_asset(
|
||||
@@ -1035,6 +1100,21 @@ def _resolve_webapp_js_asset_name() -> str:
|
||||
return "subscription_webapp.js"
|
||||
|
||||
|
||||
def _resolve_webapp_css_asset_name() -> str:
|
||||
hashed_assets = []
|
||||
for path in ASSET_DIR.glob("subscription_webapp.*.css"):
|
||||
if not re.fullmatch(r"subscription_webapp\.[0-9a-f]{8}\.css", path.name):
|
||||
continue
|
||||
try:
|
||||
hashed_assets.append((path.stat().st_mtime, path.name))
|
||||
except OSError:
|
||||
continue
|
||||
if hashed_assets:
|
||||
hashed_assets.sort(reverse=True)
|
||||
return hashed_assets[0][1]
|
||||
return "subscription_webapp.css"
|
||||
|
||||
|
||||
_INITIAL_THEME_TOKEN_CSS_MAP = {
|
||||
"accent": "--accent",
|
||||
"bg": "--bg",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from typing import Literal
|
||||
|
||||
|
||||
class WebAppEmailPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
@@ -69,3 +71,52 @@ class WebAppDeviceDisconnectPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
token: constr(min_length=8, max_length=128)
|
||||
|
||||
|
||||
SupportCategory = Literal["billing", "technical", "account", "other"]
|
||||
SupportPriority = Literal["low", "normal", "high", "urgent"]
|
||||
SupportStatus = Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]
|
||||
|
||||
|
||||
class CreateTicketPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
subject: constr(min_length=1, max_length=160)
|
||||
category: SupportCategory = "other"
|
||||
priority: Literal["normal", "high"] = "normal"
|
||||
body: constr(min_length=1, max_length=4000)
|
||||
|
||||
@field_validator("subject", "body")
|
||||
@classmethod
|
||||
def _strip_required_text(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("empty_text")
|
||||
return stripped
|
||||
|
||||
|
||||
class TicketReplyPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
body: constr(min_length=1, max_length=4000)
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def _strip_body(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("empty_text")
|
||||
return stripped
|
||||
|
||||
|
||||
class AdminTicketReplyPayload(TicketReplyPayload):
|
||||
is_internal_note: bool = False
|
||||
|
||||
|
||||
class AdminTicketPatchPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
status: Optional[SupportStatus] = None
|
||||
priority: Optional[SupportPriority] = None
|
||||
category: Optional[SupportCategory] = None
|
||||
assigned_admin_id: Optional[int] = None
|
||||
|
||||
@@ -9,15 +9,18 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/invite", index_route)
|
||||
app.router.add_get("/devices", index_route)
|
||||
app.router.add_get("/settings", index_route)
|
||||
app.router.add_get("/support", index_route)
|
||||
app.router.add_get("/support/{ticket_id:\\d+}", index_route)
|
||||
app.router.add_get("/admin", index_route)
|
||||
app.router.add_get(
|
||||
(
|
||||
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
||||
"appearance|settings}"
|
||||
"appearance|settings|support}"
|
||||
),
|
||||
index_route,
|
||||
)
|
||||
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
|
||||
app.router.add_get("/admin/support/{ticket_id:\\d+}", index_route)
|
||||
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
|
||||
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
|
||||
app.router.add_get("/health", health_route)
|
||||
@@ -34,6 +37,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
|
||||
webapp_animated_emoji_route,
|
||||
)
|
||||
app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route)
|
||||
app.router.add_get("/subscription_webapp.css", css_asset_route)
|
||||
app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route)
|
||||
app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route)
|
||||
@@ -47,6 +51,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_post("/api/auth/email/password", email_password_auth_route)
|
||||
app.router.add_post("/api/auth/logout", logout_route)
|
||||
app.router.add_get("/api/bootstrap", bootstrap_route)
|
||||
app.router.add_get("/api/i18n", i18n_route)
|
||||
app.router.add_get("/api/me", me_route)
|
||||
app.router.add_get("/api/account/avatar", account_avatar_route)
|
||||
app.router.add_post("/api/account/language", account_language_route)
|
||||
@@ -60,6 +65,12 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/devices", devices_route)
|
||||
app.router.add_post("/api/devices/disconnect", disconnect_device_route)
|
||||
app.router.add_get("/api/devices/topup-options", device_topup_options_route)
|
||||
app.router.add_get("/api/support/tickets", support_tickets_route)
|
||||
app.router.add_post("/api/support/tickets", support_create_ticket_route)
|
||||
app.router.add_get("/api/support/tickets/{id:\\d+}", support_ticket_detail_route)
|
||||
app.router.add_post("/api/support/tickets/{id:\\d+}/messages", support_ticket_reply_route)
|
||||
app.router.add_post("/api/support/tickets/{id:\\d+}/read", support_ticket_read_route)
|
||||
app.router.add_get("/api/support/unread", support_unread_route)
|
||||
app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route)
|
||||
app.router.add_get("/api/tariffs/change-options", tariff_change_options_route)
|
||||
app.router.add_post("/api/tariffs/change", tariff_change_route)
|
||||
|
||||
@@ -38,6 +38,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
if referral_service
|
||||
else {"invited_count": 0, "purchased_count": 0}
|
||||
)
|
||||
support_unread_count = (
|
||||
await support_dal.count_user_unread(session, user_id)
|
||||
if settings.SUPPORT_TICKETS_ENABLED
|
||||
else 0
|
||||
)
|
||||
local_sub = (
|
||||
await subscription_dal.get_active_subscription_by_user_id(
|
||||
session,
|
||||
@@ -106,8 +111,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
|
||||
enabled_only=True,
|
||||
),
|
||||
"support_unread_count": int(support_unread_count or 0),
|
||||
"settings": {
|
||||
"support_url": settings.SUPPORT_LINK,
|
||||
"support_tickets_enabled": bool(settings.SUPPORT_TICKETS_ENABLED),
|
||||
"support_ticket_max_body_length": int(settings.SUPPORT_TICKET_MAX_BODY_LENGTH or 4000),
|
||||
"support_ticket_max_subject_length": int(
|
||||
settings.SUPPORT_TICKET_MAX_SUBJECT_LENGTH or 160
|
||||
),
|
||||
"traffic_mode": bool(settings.traffic_sale_mode),
|
||||
"my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED),
|
||||
"user_hwid_device_limit": (
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.services.support_service import TicketForbidden, TicketNotFound, TicketRateLimited
|
||||
from db.dal import support_dal, user_dal
|
||||
from db.models import SupportTicket, SupportTicketMessage
|
||||
|
||||
|
||||
def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
|
||||
return {
|
||||
"ticket_id": ticket.ticket_id,
|
||||
"user_id": ticket.user_id,
|
||||
"subject": ticket.subject,
|
||||
"category": ticket.category,
|
||||
"priority": ticket.priority,
|
||||
"status": ticket.status,
|
||||
"assigned_admin_id": ticket.assigned_admin_id,
|
||||
"last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
|
||||
"last_message_role": ticket.last_message_role,
|
||||
"unread_user_count": int(ticket.unread_user_count or 0),
|
||||
"unread_admin_count": int(ticket.unread_admin_count or 0),
|
||||
"created_at": ticket.created_at.isoformat() if ticket.created_at else None,
|
||||
"updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
|
||||
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _support_message_payload(message: SupportTicketMessage) -> Dict[str, Any]:
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
"ticket_id": message.ticket_id,
|
||||
"author_role": message.author_role,
|
||||
"author_user_id": message.author_user_id,
|
||||
"body": message.body,
|
||||
"is_internal_note": bool(message.is_internal_note),
|
||||
"created_at": message.created_at.isoformat() if message.created_at else None,
|
||||
"read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
|
||||
"read_by_admin_at": message.read_by_admin_at.isoformat()
|
||||
if message.read_by_admin_at
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def _support_limit_offset(request: web.Request) -> tuple[int, int]:
|
||||
limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
|
||||
offset = max(0, int(request.query.get("offset", 0) or 0))
|
||||
return limit, offset
|
||||
|
||||
|
||||
async def support_tickets_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
limit, offset = _support_limit_offset(request)
|
||||
status_filter = request.query.get("status") or None
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
tickets = await support_dal.list_user_tickets(
|
||||
session,
|
||||
user_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
status_filter=status_filter,
|
||||
)
|
||||
counts = await support_dal.user_ticket_counts(session, user_id)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tickets": [_support_ticket_payload(t) for t in tickets],
|
||||
"counts": counts,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def support_create_ticket_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
payload, error = _validate_model_payload(CreateTicketPayload, await _read_json(request))
|
||||
if error:
|
||||
return error
|
||||
service = request.app["support_service"]
|
||||
try:
|
||||
ticket = await service.create_ticket(
|
||||
user_id,
|
||||
payload.subject,
|
||||
payload.category,
|
||||
payload.priority,
|
||||
payload.body,
|
||||
)
|
||||
except TicketForbidden:
|
||||
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
|
||||
except TicketRateLimited:
|
||||
return _json_error(429, "ticket_rate_limited", "Too many support tickets")
|
||||
return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
|
||||
|
||||
|
||||
async def support_ticket_detail_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=False)
|
||||
if not ticket or ticket.user_id != user_id:
|
||||
return _json_error(404, "not_found", "Ticket not found")
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"ticket": _support_ticket_payload(ticket),
|
||||
"messages": [_support_message_payload(m) for m in messages],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def support_ticket_reply_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
payload, error = _validate_model_payload(TicketReplyPayload, await _read_json(request))
|
||||
if error:
|
||||
return error
|
||||
service = request.app["support_service"]
|
||||
try:
|
||||
ticket, message = await service.reply_as_user(user_id, ticket_id, payload.body)
|
||||
except TicketForbidden:
|
||||
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
|
||||
except TicketNotFound:
|
||||
return _json_error(404, "not_found", "Ticket not found")
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"ticket": _support_ticket_payload(ticket),
|
||||
"message": _support_message_payload(message),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def support_ticket_read_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
service = request.app["support_service"]
|
||||
try:
|
||||
await service.mark_read_as_user(user_id, ticket_id)
|
||||
except TicketNotFound:
|
||||
return _json_error(404, "not_found", "Ticket not found")
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def support_unread_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
if user and user.is_banned:
|
||||
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
|
||||
unread = await support_dal.count_user_unread(session, user_id)
|
||||
return web.json_response({"ok": True, "unread": unread})
|
||||
@@ -387,11 +387,12 @@ async def ensure_required_channel_subscription(
|
||||
)
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
|
||||
@router.message(
|
||||
CommandStart(
|
||||
magic=F.args.regexp(
|
||||
r"^(?!ref_|promo_|admin_user_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
|
||||
r"^(?!ref_|promo_|admin_user_|ticket_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
|
||||
).as_("ad_param_match")
|
||||
)
|
||||
)
|
||||
@@ -408,6 +409,7 @@ async def start_command_handler(
|
||||
page_ref_match: Optional[re.Match] = None,
|
||||
ad_param_match: Optional[re.Match] = None,
|
||||
admin_user_match: Optional[re.Match] = None,
|
||||
ticket_match: Optional[re.Match] = None,
|
||||
):
|
||||
await state.clear()
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
@@ -465,6 +467,31 @@ async def start_command_handler(
|
||||
await message.answer(_("admin_user_card_error"))
|
||||
return
|
||||
|
||||
if ticket_match:
|
||||
ticket_id = int(ticket_match.group(1))
|
||||
base_url = (settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
|
||||
if base_url:
|
||||
ticket_url = f"{base_url.rstrip('/')}/support/{ticket_id}"
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=i18n.gettext(current_lang, "wa_support_open_ticket")
|
||||
if i18n
|
||||
else "Открыть тикет",
|
||||
web_app=types.WebAppInfo(url=ticket_url),
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await message.answer(
|
||||
i18n.gettext(current_lang, "wa_support_open_ticket_hint")
|
||||
if i18n
|
||||
else "Откройте тикет в Mini App.",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return
|
||||
|
||||
referred_by_user_id: Optional[int] = None
|
||||
promo_code_to_apply: Optional[str] = None
|
||||
should_open_referral_from_start = False
|
||||
|
||||
@@ -196,6 +196,9 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
"promo_code_service",
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"support_service",
|
||||
"notification_service",
|
||||
"email_auth_service",
|
||||
*iter_service_keys(),
|
||||
):
|
||||
await close_service(service_key)
|
||||
|
||||
@@ -492,3 +492,155 @@ def render_subscription_expiring(
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def _support_email(
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n],
|
||||
language: Optional[str],
|
||||
*,
|
||||
subject: str,
|
||||
heading: str,
|
||||
intro: str,
|
||||
rows: Sequence[Tuple[str, str]],
|
||||
body_preview: str,
|
||||
ticket_url: Optional[str],
|
||||
cta_label: str,
|
||||
) -> EmailContent:
|
||||
lang = _normalize_lang(language, settings)
|
||||
brand = _brand_title(settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
safe_url = (ticket_url or "").strip()
|
||||
footer = _t_html(_resolve_i18n(i18n), lang, "email_footer_auto", brand=brand)
|
||||
preview_block = (
|
||||
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
|
||||
f'border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};'
|
||||
f'white-space:pre-wrap;">{html.escape(body_preview or "")}</div>'
|
||||
)
|
||||
body_parts = [_info_rows_html(rows), preview_block]
|
||||
if safe_url:
|
||||
body_parts.append(_cta_button_html(label=cta_label, url=safe_url, accent=accent))
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=intro,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
)
|
||||
text_lines = [intro, "", *[f"{label}: {value}" for label, value in rows], "", body_preview]
|
||||
if safe_url:
|
||||
text_lines.extend(["", safe_url])
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def render_support_new_ticket_admin(
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n],
|
||||
language: Optional[str],
|
||||
*,
|
||||
ticket_id: int,
|
||||
user_display: str,
|
||||
subject: str,
|
||||
body_preview: str,
|
||||
snapshot_rows: Sequence[Tuple[str, str]],
|
||||
ticket_url: Optional[str],
|
||||
) -> EmailContent:
|
||||
rows = [
|
||||
("Ticket", f"#{ticket_id}"),
|
||||
("User", user_display),
|
||||
("Subject", subject),
|
||||
*snapshot_rows,
|
||||
]
|
||||
return _support_email(
|
||||
settings,
|
||||
i18n,
|
||||
language,
|
||||
subject=f"New support ticket #{ticket_id}",
|
||||
heading=f"New support ticket #{ticket_id}",
|
||||
intro="A user opened a new support ticket.",
|
||||
rows=rows,
|
||||
body_preview=body_preview,
|
||||
ticket_url=ticket_url,
|
||||
cta_label="Open ticket",
|
||||
)
|
||||
|
||||
|
||||
def render_support_user_reply_admin(
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n],
|
||||
language: Optional[str],
|
||||
*,
|
||||
ticket_id: int,
|
||||
user_display: str,
|
||||
subject: str,
|
||||
body_preview: str,
|
||||
snapshot_rows: Sequence[Tuple[str, str]],
|
||||
ticket_url: Optional[str],
|
||||
) -> EmailContent:
|
||||
rows = [
|
||||
("Ticket", f"#{ticket_id}"),
|
||||
("User", user_display),
|
||||
("Subject", subject),
|
||||
*snapshot_rows,
|
||||
]
|
||||
return _support_email(
|
||||
settings,
|
||||
i18n,
|
||||
language,
|
||||
subject=f"New user reply in ticket #{ticket_id}",
|
||||
heading=f"User replied in ticket #{ticket_id}",
|
||||
intro="A user sent a new support message.",
|
||||
rows=rows,
|
||||
body_preview=body_preview,
|
||||
ticket_url=ticket_url,
|
||||
cta_label="Open ticket",
|
||||
)
|
||||
|
||||
|
||||
def render_support_admin_reply_user(
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n],
|
||||
language: Optional[str],
|
||||
*,
|
||||
ticket_id: int,
|
||||
subject: str,
|
||||
body_preview: str,
|
||||
ticket_url: Optional[str],
|
||||
) -> EmailContent:
|
||||
return _support_email(
|
||||
settings,
|
||||
i18n,
|
||||
language,
|
||||
subject=f"New reply for ticket #{ticket_id}",
|
||||
heading=f"New reply for ticket #{ticket_id}",
|
||||
intro="Support has replied to your ticket.",
|
||||
rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
|
||||
body_preview=body_preview,
|
||||
ticket_url=ticket_url,
|
||||
cta_label="Open in Mini App",
|
||||
)
|
||||
|
||||
|
||||
def render_support_ticket_closed_user(
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n],
|
||||
language: Optional[str],
|
||||
*,
|
||||
ticket_id: int,
|
||||
subject: str,
|
||||
body_preview: str = "",
|
||||
ticket_url: Optional[str],
|
||||
) -> EmailContent:
|
||||
return _support_email(
|
||||
settings,
|
||||
i18n,
|
||||
language,
|
||||
subject=f"Ticket #{ticket_id} was closed",
|
||||
heading=f"Ticket #{ticket_id} was closed",
|
||||
intro="Your support ticket has been closed.",
|
||||
rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
|
||||
body_preview=body_preview or "The ticket is closed.",
|
||||
ticket_url=ticket_url,
|
||||
cta_label="Open in Mini App",
|
||||
)
|
||||
|
||||
@@ -4,10 +4,18 @@ from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.email_auth_service import EmailAuthService
|
||||
from bot.services.email_templates import (
|
||||
render_support_admin_reply_user,
|
||||
render_support_new_ticket_admin,
|
||||
render_support_ticket_closed_user,
|
||||
render_support_user_reply_admin,
|
||||
)
|
||||
from bot.utils import MessageContent, send_message_via_queue
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
from bot.utils.telegram_markup import (
|
||||
is_profile_link_error,
|
||||
@@ -18,15 +26,30 @@ from bot.utils.text_sanitizer import (
|
||||
username_for_display,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import app_settings_dal, user_dal
|
||||
|
||||
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_KEY = "SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED"
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Enhanced notification service for sending messages to admins and log channels"""
|
||||
|
||||
def __init__(self, bot: Bot, settings: Settings, i18n: Optional[JsonI18n] = None):
|
||||
def __init__(
|
||||
self,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
*,
|
||||
session_factory=None,
|
||||
email_auth_service: Optional[EmailAuthService] = None,
|
||||
bot_username: Optional[str] = None,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.session_factory = session_factory
|
||||
self.email_auth_service = email_auth_service
|
||||
self.bot_username = bot_username or ""
|
||||
|
||||
@staticmethod
|
||||
def _format_user_display(
|
||||
@@ -149,7 +172,11 @@ class NotificationService:
|
||||
"Failed to queue notification to log channel %s.", self.settings.LOG_CHAT_ID
|
||||
)
|
||||
|
||||
async def _send_to_admins(self, message: str):
|
||||
async def _send_to_admins(
|
||||
self,
|
||||
message: str,
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||
):
|
||||
"""Send message to all admin users using message queue"""
|
||||
if not self.settings.ADMIN_IDS:
|
||||
return
|
||||
@@ -164,6 +191,7 @@ class NotificationService:
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to send notification to admin %s.", admin_id)
|
||||
@@ -172,11 +200,343 @@ class NotificationService:
|
||||
for admin_id in self.settings.ADMIN_IDS:
|
||||
try:
|
||||
await queue_manager.send_message(
|
||||
chat_id=admin_id, text=message, parse_mode="HTML", disable_web_page_preview=True
|
||||
chat_id=admin_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to queue notification to admin %s.", admin_id)
|
||||
|
||||
def _support_webapp_url(self, path: str) -> Optional[str]:
|
||||
base_url = str(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
||||
if not base_url:
|
||||
return None
|
||||
normalized_path = f"/{str(path or '').lstrip('/')}"
|
||||
return f"{base_url.rstrip('/')}{normalized_path}"
|
||||
|
||||
def _support_ticket_url(self, ticket_id: int, *, admin: bool = True) -> str:
|
||||
path = f"/admin/support/{ticket_id}" if admin else f"/support/{ticket_id}"
|
||||
webapp_url = self._support_webapp_url(path)
|
||||
if webapp_url:
|
||||
return webapp_url
|
||||
bot_username = self.bot_username.strip().lstrip("@")
|
||||
if bot_username:
|
||||
return f"https://t.me/{bot_username}?startapp=ticket_{ticket_id}"
|
||||
return "https://t.me/"
|
||||
|
||||
def _support_mini_app_button(
|
||||
self,
|
||||
*,
|
||||
text: str,
|
||||
path: str,
|
||||
fallback_url: str,
|
||||
) -> InlineKeyboardButton:
|
||||
webapp_url = self._support_webapp_url(path)
|
||||
if webapp_url:
|
||||
return InlineKeyboardButton(text=text, web_app=WebAppInfo(url=webapp_url))
|
||||
return InlineKeyboardButton(text=text, url=fallback_url)
|
||||
|
||||
def _support_text(self, language: Optional[str], key: str, fallback: str) -> str:
|
||||
if not self.i18n:
|
||||
return fallback
|
||||
return self.i18n.gettext(language or self.settings.DEFAULT_LANGUAGE, key) or fallback
|
||||
|
||||
@staticmethod
|
||||
def _support_preview(body: str, limit: int = 700) -> str:
|
||||
text = (body or "").strip()
|
||||
return text if len(text) <= limit else f"{text[: limit - 1]}…"
|
||||
|
||||
@staticmethod
|
||||
def _support_user_display(user) -> str:
|
||||
name = " ".join(
|
||||
part for part in [user.first_name, getattr(user, "last_name", None)] if part
|
||||
)
|
||||
if user.username:
|
||||
return f"{name or user.username} (@{user.username})"
|
||||
return name or getattr(user, "email", None) or f"ID {user.user_id}"
|
||||
|
||||
@staticmethod
|
||||
def _support_snapshot_rows(snapshot: Optional[dict]) -> list[tuple[str, str]]:
|
||||
if not snapshot:
|
||||
return []
|
||||
rows = []
|
||||
for key, label in (
|
||||
("tariff", "Tariff"),
|
||||
("end_date", "End date"),
|
||||
("remaining", "Remaining"),
|
||||
("panel_status", "Panel status"),
|
||||
):
|
||||
value = snapshot.get(key)
|
||||
if value:
|
||||
rows.append((label, str(value)))
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def _coerce_bool_setting(value: Any, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
async def support_admin_email_notifications_enabled(self) -> bool:
|
||||
enabled = bool(
|
||||
getattr(self.settings, SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_KEY, False)
|
||||
)
|
||||
if not self.session_factory:
|
||||
return enabled
|
||||
try:
|
||||
async with self.session_factory() as session:
|
||||
found, raw_value = await app_settings_dal.get_override_value(
|
||||
session,
|
||||
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_KEY,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to read support admin email notification override.")
|
||||
return enabled
|
||||
if not found:
|
||||
return enabled
|
||||
return self._coerce_bool_setting(raw_value, enabled)
|
||||
|
||||
def _support_keyboard(self, ticket, user, *, admin: bool = True) -> InlineKeyboardMarkup:
|
||||
ticket_path = (
|
||||
f"/admin/support/{ticket.ticket_id}" if admin else f"/support/{ticket.ticket_id}"
|
||||
)
|
||||
rows = [
|
||||
[
|
||||
self._support_mini_app_button(
|
||||
text="Открыть тикет",
|
||||
path=ticket_path,
|
||||
fallback_url=self._support_ticket_url(ticket.ticket_id, admin=admin),
|
||||
)
|
||||
]
|
||||
]
|
||||
if admin:
|
||||
profile_row = []
|
||||
if getattr(user, "user_id", 0) and int(user.user_id) > 0:
|
||||
profile_row.append(
|
||||
InlineKeyboardButton(text="Профиль", url=f"tg://user?id={user.user_id}")
|
||||
)
|
||||
user_card_path = f"/admin/users/{user.user_id}"
|
||||
if self._support_webapp_url(user_card_path):
|
||||
profile_row.append(
|
||||
self._support_mini_app_button(
|
||||
text="Карточка пользователя",
|
||||
path=user_card_path,
|
||||
fallback_url=self._support_ticket_url(ticket.ticket_id, admin=True),
|
||||
)
|
||||
)
|
||||
if profile_row:
|
||||
rows.append(profile_row)
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
def _support_user_keyboard(self, ticket, user) -> InlineKeyboardMarkup:
|
||||
button_text = self._support_text(
|
||||
getattr(user, "language_code", None),
|
||||
"wa_support_open_ticket",
|
||||
"Открыть тикет",
|
||||
)
|
||||
webapp_url = self._support_webapp_url(f"/support/{ticket.ticket_id}")
|
||||
if webapp_url:
|
||||
button = InlineKeyboardButton(text=button_text, web_app=WebAppInfo(url=webapp_url))
|
||||
else:
|
||||
button = InlineKeyboardButton(
|
||||
text=button_text,
|
||||
url=self._support_ticket_url(ticket.ticket_id, admin=False),
|
||||
)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[[button]])
|
||||
|
||||
async def _admin_email_users(self):
|
||||
if not self.session_factory:
|
||||
return []
|
||||
async with self.session_factory() as session:
|
||||
users = []
|
||||
for admin_id in self.settings.ADMIN_IDS:
|
||||
user = await user_dal.get_user_by_id(session, int(admin_id))
|
||||
if user and user.email:
|
||||
users.append(user)
|
||||
return users
|
||||
|
||||
async def _send_admin_support_email(self, renderer, **kwargs) -> None:
|
||||
if not await self.support_admin_email_notifications_enabled():
|
||||
return
|
||||
if not self.email_auth_service:
|
||||
return
|
||||
for admin in await self._admin_email_users():
|
||||
try:
|
||||
content = renderer(
|
||||
self.settings,
|
||||
self.i18n,
|
||||
getattr(admin, "language_code", None) or self.settings.DEFAULT_LANGUAGE,
|
||||
**kwargs,
|
||||
)
|
||||
await self.email_auth_service.send_rendered_email(
|
||||
email=admin.email,
|
||||
content=content,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to send support email to admin %s.", admin.user_id)
|
||||
|
||||
async def notify_new_support_ticket(self, ticket, user, first_message: str, snapshot: dict):
|
||||
if not getattr(self.settings, "LOG_SUPPORT", True):
|
||||
return
|
||||
priority_emoji = {"low": "🟢", "normal": "🟡", "high": "🟠", "urgent": "🔴"}.get(
|
||||
ticket.priority,
|
||||
"🟡",
|
||||
)
|
||||
preview = self._support_preview(first_message)
|
||||
user_display = self._support_user_display(user)
|
||||
message = (
|
||||
f"🆘 <b>Новый тикет #{ticket.ticket_id}</b>\n"
|
||||
f"{priority_emoji} <b>{hd.quote(ticket.priority)}</b> · {hd.quote(ticket.category)}\n\n"
|
||||
f"<b>Пользователь</b>\n{hd.quote(user_display)}\nID: <code>{user.user_id}</code>\n\n"
|
||||
f"<b>Подписка</b>\n"
|
||||
f"{hd.quote(str(snapshot.get('tariff') or '—'))}, "
|
||||
f"до {hd.quote(str(snapshot.get('end_date') or '—'))}, "
|
||||
f"осталось {hd.quote(str(snapshot.get('remaining') or '—'))}\n"
|
||||
f"статус: {hd.quote(str(snapshot.get('panel_status') or '—'))}\n\n"
|
||||
f"<b>Текст обращения</b>\n{hd.quote(preview)}"
|
||||
)
|
||||
keyboard = self._support_keyboard(ticket, user, admin=True)
|
||||
await self._send_to_admins(message, reply_markup=keyboard)
|
||||
await self._send_to_log_channel(
|
||||
message,
|
||||
thread_id=getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await self._send_admin_support_email(
|
||||
render_support_new_ticket_admin,
|
||||
ticket_id=ticket.ticket_id,
|
||||
user_display=user_display,
|
||||
subject=ticket.subject,
|
||||
body_preview=preview,
|
||||
snapshot_rows=self._support_snapshot_rows(snapshot),
|
||||
ticket_url=self._support_ticket_url(ticket.ticket_id, admin=True),
|
||||
)
|
||||
|
||||
async def notify_support_user_reply(
|
||||
self,
|
||||
ticket,
|
||||
message,
|
||||
user,
|
||||
snapshot: dict,
|
||||
*,
|
||||
unread_count: Optional[int] = None,
|
||||
send_telegram: bool = True,
|
||||
send_email: bool = True,
|
||||
):
|
||||
if not getattr(self.settings, "LOG_SUPPORT", True):
|
||||
return
|
||||
preview = self._support_preview(message.body)
|
||||
user_display = self._support_user_display(user)
|
||||
unread_line = (
|
||||
f"\n<b>Unread:</b> {int(unread_count)}"
|
||||
if unread_count is not None and int(unread_count or 0) > 1
|
||||
else ""
|
||||
)
|
||||
text = (
|
||||
f"💬 <b>Ответ пользователя в тикете #{ticket.ticket_id}</b>\n"
|
||||
f"{hd.quote(user_display)}{unread_line}\n\n{hd.quote(preview)}"
|
||||
)
|
||||
keyboard = self._support_keyboard(ticket, user, admin=True)
|
||||
if send_telegram and getattr(self.settings, "LOG_SUPPORT", True):
|
||||
await self._send_to_admins(text, reply_markup=keyboard)
|
||||
await self._send_to_log_channel(
|
||||
text,
|
||||
thread_id=getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
if send_email:
|
||||
await self._send_admin_support_email(
|
||||
render_support_user_reply_admin,
|
||||
ticket_id=ticket.ticket_id,
|
||||
user_display=user_display,
|
||||
subject=ticket.subject,
|
||||
body_preview=preview,
|
||||
snapshot_rows=self._support_snapshot_rows(snapshot),
|
||||
ticket_url=self._support_ticket_url(ticket.ticket_id, admin=True),
|
||||
)
|
||||
|
||||
async def notify_support_admin_reply(self, ticket, message, user):
|
||||
preview = self._support_preview(message.body, limit=500)
|
||||
url = self._support_ticket_url(ticket.ticket_id, admin=False)
|
||||
text = f"💬 <b>Новый ответ по тикету #{ticket.ticket_id}</b>\n\n{hd.quote(preview)}"
|
||||
keyboard = self._support_user_keyboard(ticket, user)
|
||||
if int(user.user_id) > 0:
|
||||
queue_manager = get_queue_manager()
|
||||
if queue_manager:
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
int(user.user_id),
|
||||
MessageContent(content_type="text", text=text),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await self.bot.send_message(
|
||||
chat_id=int(user.user_id),
|
||||
text=text,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
if self.email_auth_service and getattr(user, "email", None):
|
||||
content = render_support_admin_reply_user(
|
||||
self.settings,
|
||||
self.i18n,
|
||||
getattr(user, "language_code", None),
|
||||
ticket_id=ticket.ticket_id,
|
||||
subject=ticket.subject,
|
||||
body_preview=preview,
|
||||
ticket_url=url,
|
||||
)
|
||||
await self.email_auth_service.send_rendered_email(email=user.email, content=content)
|
||||
|
||||
async def notify_support_ticket_closed(self, ticket, user, closing_admin):
|
||||
url = self._support_ticket_url(ticket.ticket_id, admin=False)
|
||||
text = f"✅ <b>Тикет #{ticket.ticket_id} закрыт</b>\n\n{hd.quote(ticket.subject)}"
|
||||
keyboard = self._support_user_keyboard(ticket, user)
|
||||
if int(user.user_id) > 0:
|
||||
queue_manager = get_queue_manager()
|
||||
if queue_manager:
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
int(user.user_id),
|
||||
MessageContent(content_type="text", text=text),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await self.bot.send_message(
|
||||
chat_id=int(user.user_id),
|
||||
text=text,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
if self.email_auth_service and getattr(user, "email", None):
|
||||
content = render_support_ticket_closed_user(
|
||||
self.settings,
|
||||
self.i18n,
|
||||
getattr(user, "language_code", None),
|
||||
ticket_id=ticket.ticket_id,
|
||||
subject=ticket.subject,
|
||||
ticket_url=url,
|
||||
)
|
||||
await self.email_auth_service.send_rendered_email(email=user.email, content=content)
|
||||
|
||||
async def notify_new_user_registration(
|
||||
self,
|
||||
user_id: int,
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.email_auth_service import EmailAuthService
|
||||
from bot.services.notification_service import NotificationService
|
||||
from config.settings import Settings
|
||||
from db.dal import message_log_dal, subscription_dal, support_dal, user_dal
|
||||
from db.models import SupportTicket, SupportTicketMessage, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdminNotificationDecision:
|
||||
send_telegram: bool
|
||||
send_email: bool
|
||||
|
||||
|
||||
def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _notification_due(
|
||||
last_sent_at: Optional[datetime],
|
||||
*,
|
||||
now: datetime,
|
||||
cooldown_seconds: int,
|
||||
) -> bool:
|
||||
last_sent_at = _as_utc(last_sent_at)
|
||||
if last_sent_at is None:
|
||||
return True
|
||||
cooldown = max(0, int(cooldown_seconds or 0))
|
||||
if cooldown <= 0:
|
||||
return True
|
||||
return (now - last_sent_at).total_seconds() >= cooldown
|
||||
|
||||
|
||||
def _support_admin_notification_decision(
|
||||
ticket: SupportTicket,
|
||||
settings: Settings,
|
||||
*,
|
||||
now: Optional[datetime] = None,
|
||||
admin_email_notifications_enabled: Optional[bool] = None,
|
||||
) -> AdminNotificationDecision:
|
||||
now = _as_utc(now) or datetime.now(timezone.utc)
|
||||
unread_count = max(0, int(getattr(ticket, "unread_admin_count", 0) or 0))
|
||||
if unread_count <= 0:
|
||||
return AdminNotificationDecision(send_telegram=False, send_email=False)
|
||||
|
||||
first_unread = unread_count <= 1
|
||||
send_telegram = first_unread or _notification_due(
|
||||
getattr(ticket, "admin_last_notified_at", None),
|
||||
now=now,
|
||||
cooldown_seconds=getattr(settings, "SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS", 300),
|
||||
)
|
||||
if admin_email_notifications_enabled is None:
|
||||
admin_email_notifications_enabled = bool(
|
||||
getattr(settings, "SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED", False)
|
||||
)
|
||||
send_email = bool(admin_email_notifications_enabled) and (
|
||||
first_unread
|
||||
or _notification_due(
|
||||
getattr(ticket, "admin_last_emailed_at", None),
|
||||
now=now,
|
||||
cooldown_seconds=getattr(settings, "SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS", 1800),
|
||||
)
|
||||
)
|
||||
return AdminNotificationDecision(send_telegram=send_telegram, send_email=send_email)
|
||||
|
||||
|
||||
def _format_support_remaining(seconds: int, lang: str) -> str:
|
||||
if seconds <= 0:
|
||||
return "Subscription inactive" if lang == "en" else "Подписка не активна"
|
||||
days, rem = divmod(seconds, 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
minutes = rem // 60
|
||||
if lang == "en":
|
||||
if days > 0:
|
||||
return f"{days} d. {hours} h."
|
||||
if hours > 0:
|
||||
return f"{hours} h. {minutes} min."
|
||||
return f"{max(1, minutes)} min."
|
||||
if days > 0:
|
||||
return f"{days} д. {hours} ч."
|
||||
if hours > 0:
|
||||
return f"{hours} ч. {minutes} мин."
|
||||
return f"{max(1, minutes)} мин."
|
||||
|
||||
|
||||
class TicketForbidden(PermissionError):
|
||||
pass
|
||||
|
||||
|
||||
class TicketRateLimited(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class TicketNotFound(LookupError):
|
||||
pass
|
||||
|
||||
|
||||
class SupportService:
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: sessionmaker,
|
||||
settings: Settings,
|
||||
bot: Bot,
|
||||
i18n: Optional[JsonI18n],
|
||||
notification_service: Optional[NotificationService] = None,
|
||||
email_auth_service: Optional[EmailAuthService] = None,
|
||||
):
|
||||
self.session_factory = session_factory
|
||||
self.settings = settings
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
self.email_auth_service = email_auth_service or EmailAuthService(settings)
|
||||
self.notification_service = notification_service or NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
session_factory=session_factory,
|
||||
email_auth_service=self.email_auth_service,
|
||||
)
|
||||
|
||||
async def _ensure_user_allowed(self, session, user_id: int) -> User:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not user or user.is_banned or not self.settings.SUPPORT_TICKETS_ENABLED:
|
||||
raise TicketForbidden("ticket_forbidden")
|
||||
return user
|
||||
|
||||
async def create_ticket(
|
||||
self,
|
||||
user_id: int,
|
||||
subject: str,
|
||||
category: str,
|
||||
priority: str,
|
||||
first_message_body: str,
|
||||
) -> SupportTicket:
|
||||
async with self.session_factory() as session:
|
||||
user = await self._ensure_user_allowed(session, user_id)
|
||||
limit = max(0, int(self.settings.SUPPORT_TICKET_RATE_LIMIT_PER_HOUR or 0))
|
||||
if limit:
|
||||
recent = await support_dal.count_recent_tickets_for_user(session, user_id, 3600)
|
||||
if recent >= limit:
|
||||
raise TicketRateLimited("ticket_rate_limited")
|
||||
ticket = await support_dal.create_ticket(
|
||||
session,
|
||||
user_id,
|
||||
subject[: self.settings.SUPPORT_TICKET_MAX_SUBJECT_LENGTH],
|
||||
category,
|
||||
priority,
|
||||
first_message_body[: self.settings.SUPPORT_TICKET_MAX_BODY_LENGTH],
|
||||
)
|
||||
snapshot = await self.build_user_snapshot(user, session=session)
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
await self.notification_service.notify_new_support_ticket(
|
||||
ticket,
|
||||
user,
|
||||
first_message_body,
|
||||
snapshot,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to notify about support ticket %s", ticket.ticket_id)
|
||||
return ticket
|
||||
|
||||
async def reply_as_user(
|
||||
self,
|
||||
user_id: int,
|
||||
ticket_id: int,
|
||||
body: str,
|
||||
) -> tuple[SupportTicket, SupportTicketMessage]:
|
||||
async with self.session_factory() as session:
|
||||
user = await self._ensure_user_allowed(session, user_id)
|
||||
ticket, _messages = await support_dal.get_ticket(session, ticket_id)
|
||||
if not ticket or ticket.user_id != user_id:
|
||||
raise TicketNotFound("not_found")
|
||||
message = await support_dal.add_message(
|
||||
session,
|
||||
ticket_id,
|
||||
"user",
|
||||
user_id,
|
||||
body[: self.settings.SUPPORT_TICKET_MAX_BODY_LENGTH],
|
||||
)
|
||||
if message is None:
|
||||
raise TicketNotFound("not_found")
|
||||
await session.refresh(ticket)
|
||||
notification_at = datetime.now(timezone.utc)
|
||||
admin_email_notifications_enabled = (
|
||||
await self.notification_service.support_admin_email_notifications_enabled()
|
||||
)
|
||||
notification_decision = _support_admin_notification_decision(
|
||||
ticket,
|
||||
self.settings,
|
||||
now=notification_at,
|
||||
admin_email_notifications_enabled=admin_email_notifications_enabled,
|
||||
)
|
||||
await support_dal.record_admin_notification(
|
||||
session,
|
||||
ticket_id,
|
||||
notified_at=notification_at if notification_decision.send_telegram else None,
|
||||
emailed_at=notification_at if notification_decision.send_email else None,
|
||||
)
|
||||
snapshot = await self.build_user_snapshot(user, session=session)
|
||||
await session.commit()
|
||||
|
||||
if notification_decision.send_telegram or notification_decision.send_email:
|
||||
try:
|
||||
await self.notification_service.notify_support_user_reply(
|
||||
ticket,
|
||||
message,
|
||||
user,
|
||||
snapshot,
|
||||
unread_count=int(ticket.unread_admin_count or 0),
|
||||
send_telegram=notification_decision.send_telegram,
|
||||
send_email=notification_decision.send_email,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to notify about support user reply %s", ticket_id)
|
||||
return ticket, message
|
||||
|
||||
async def reply_as_admin(
|
||||
self,
|
||||
admin_id: int,
|
||||
ticket_id: int,
|
||||
body: str,
|
||||
*,
|
||||
is_internal_note: bool = False,
|
||||
) -> tuple[SupportTicket, SupportTicketMessage]:
|
||||
async with self.session_factory() as session:
|
||||
ticket, _messages = await support_dal.get_ticket(
|
||||
session,
|
||||
ticket_id,
|
||||
include_internal=True,
|
||||
)
|
||||
if not ticket:
|
||||
raise TicketNotFound("not_found")
|
||||
user = await user_dal.get_user_by_id(session, ticket.user_id)
|
||||
message = await support_dal.add_message(
|
||||
session,
|
||||
ticket_id,
|
||||
"admin",
|
||||
admin_id,
|
||||
body[: self.settings.SUPPORT_TICKET_MAX_BODY_LENGTH],
|
||||
is_internal_note=is_internal_note,
|
||||
)
|
||||
if message is None:
|
||||
raise TicketNotFound("not_found")
|
||||
await support_dal.mark_read(session, ticket_id, "admin")
|
||||
if not is_internal_note:
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": admin_id,
|
||||
"event_type": "support_admin_reply",
|
||||
"content": f"Support ticket #{ticket_id} reply",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": ticket.user_id,
|
||||
},
|
||||
)
|
||||
await session.refresh(ticket)
|
||||
await session.commit()
|
||||
|
||||
if user and not is_internal_note:
|
||||
try:
|
||||
await self.notification_service.notify_support_admin_reply(ticket, message, user)
|
||||
except Exception:
|
||||
logger.exception("Failed to notify user about support admin reply %s", ticket_id)
|
||||
return ticket, message
|
||||
|
||||
async def change_status(self, admin_id: int, ticket_id: int, status: str) -> SupportTicket:
|
||||
return await self._update_and_audit(admin_id, ticket_id, status=status)
|
||||
|
||||
async def change_priority(self, admin_id: int, ticket_id: int, priority: str) -> SupportTicket:
|
||||
return await self._update_and_audit(admin_id, ticket_id, priority=priority)
|
||||
|
||||
async def change_category(self, admin_id: int, ticket_id: int, category: str) -> SupportTicket:
|
||||
return await self._update_and_audit(admin_id, ticket_id, category=category)
|
||||
|
||||
async def assign_admin(
|
||||
self,
|
||||
admin_id: int,
|
||||
ticket_id: int,
|
||||
assigned_admin_id: Optional[int],
|
||||
) -> SupportTicket:
|
||||
return await self._update_and_audit(
|
||||
admin_id,
|
||||
ticket_id,
|
||||
assigned_admin_id=assigned_admin_id,
|
||||
)
|
||||
|
||||
async def close_ticket(self, admin_id: int, ticket_id: int) -> SupportTicket:
|
||||
ticket = await self._update_and_audit(
|
||||
admin_id,
|
||||
ticket_id,
|
||||
status="closed",
|
||||
closed_by_admin_id=admin_id,
|
||||
)
|
||||
async with self.session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, ticket.user_id)
|
||||
if user:
|
||||
try:
|
||||
await self.notification_service.notify_support_ticket_closed(ticket, user, admin_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to notify user about support close %s", ticket_id)
|
||||
return ticket
|
||||
|
||||
async def _update_and_audit(
|
||||
self,
|
||||
admin_id: int,
|
||||
ticket_id: int,
|
||||
**updates: Any,
|
||||
) -> SupportTicket:
|
||||
async with self.session_factory() as session:
|
||||
ticket = await support_dal.update_ticket(session, ticket_id, **updates)
|
||||
if not ticket:
|
||||
raise TicketNotFound("not_found")
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": admin_id,
|
||||
"event_type": "support_ticket_update",
|
||||
"content": f"Support ticket #{ticket_id}: {updates}",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": ticket.user_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
return ticket
|
||||
|
||||
async def mark_read_as_user(self, user_id: int, ticket_id: int) -> None:
|
||||
async with self.session_factory() as session:
|
||||
ticket, _messages = await support_dal.get_ticket(session, ticket_id)
|
||||
if not ticket or ticket.user_id != user_id:
|
||||
raise TicketNotFound("not_found")
|
||||
await support_dal.mark_read(session, ticket_id, "user")
|
||||
await session.commit()
|
||||
|
||||
async def mark_read_as_admin(self, ticket_id: int) -> None:
|
||||
async with self.session_factory() as session:
|
||||
await support_dal.mark_read(session, ticket_id, "admin")
|
||||
await session.commit()
|
||||
|
||||
async def build_user_snapshot(self, user: User, *, session=None) -> dict:
|
||||
owns_session = session is None
|
||||
if owns_session:
|
||||
session = self.session_factory()
|
||||
await session.__aenter__()
|
||||
try:
|
||||
sub = None
|
||||
if getattr(user, "panel_user_uuid", None):
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session,
|
||||
int(user.user_id),
|
||||
user.panel_user_uuid,
|
||||
)
|
||||
lang = (
|
||||
getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE or "ru"
|
||||
).split("-")[0]
|
||||
tariff_name = ""
|
||||
if sub and getattr(sub, "tariff_key", None) and self.settings.tariffs_config:
|
||||
try:
|
||||
tariff = self.settings.tariffs_config.require(str(sub.tariff_key))
|
||||
tariff_name = tariff.name(lang)
|
||||
except Exception:
|
||||
tariff_name = str(sub.tariff_key or "")
|
||||
end_date = getattr(sub, "end_date", None)
|
||||
seconds_left = (
|
||||
max(0, int((end_date - datetime.now(timezone.utc)).total_seconds()))
|
||||
if end_date
|
||||
else 0
|
||||
)
|
||||
return {
|
||||
"user_id": int(user.user_id),
|
||||
"name": " ".join(
|
||||
part for part in [user.first_name, getattr(user, "last_name", None)] if part
|
||||
)
|
||||
or user.username
|
||||
or str(user.user_id),
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"telegram_id": user.telegram_id,
|
||||
"language": lang,
|
||||
"registration_date": (
|
||||
user.registration_date.isoformat() if user.registration_date else None
|
||||
),
|
||||
"email_login": bool(user.email and user.email_verified_at),
|
||||
"subscription_active": bool(sub),
|
||||
"panel_status": getattr(sub, "status_from_panel", None) if sub else None,
|
||||
"end_date": end_date.isoformat() if end_date else None,
|
||||
"remaining": _format_support_remaining(seconds_left, lang),
|
||||
"tariff": tariff_name or (getattr(sub, "tariff_key", None) if sub else ""),
|
||||
"traffic_regular": self._traffic_snapshot(
|
||||
getattr(sub, "traffic_used_bytes", 0) if sub else 0,
|
||||
self._regular_limit(sub),
|
||||
),
|
||||
"traffic_premium": self._traffic_snapshot(
|
||||
getattr(sub, "premium_used_bytes", 0) if sub else 0,
|
||||
self._premium_limit(sub),
|
||||
),
|
||||
"is_throttled": bool(getattr(sub, "is_throttled", False)) if sub else False,
|
||||
"topup_balance_bytes": int(getattr(sub, "topup_balance_bytes", 0) or 0)
|
||||
if sub
|
||||
else 0,
|
||||
"premium_topup_balance_bytes": int(
|
||||
getattr(sub, "premium_topup_balance_bytes", 0) or 0
|
||||
)
|
||||
if sub
|
||||
else 0,
|
||||
"lifetime_used_traffic_bytes": int(user.lifetime_used_traffic_bytes or 0),
|
||||
}
|
||||
finally:
|
||||
if owns_session:
|
||||
await session.__aexit__(None, None, None)
|
||||
|
||||
@staticmethod
|
||||
def _regular_limit(sub) -> int:
|
||||
if not sub:
|
||||
return 0
|
||||
if getattr(sub, "regular_unlimited_override", False):
|
||||
return 0
|
||||
return int(
|
||||
(sub.traffic_limit_bytes or 0)
|
||||
+ (sub.topup_balance_bytes or 0)
|
||||
+ (getattr(sub, "regular_bonus_bytes", 0) or 0)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _premium_limit(sub) -> int:
|
||||
if not sub:
|
||||
return 0
|
||||
if getattr(sub, "premium_unlimited_override", False):
|
||||
return 0
|
||||
return int(
|
||||
(sub.premium_baseline_bytes or 0)
|
||||
+ (sub.premium_topup_balance_bytes or 0)
|
||||
+ (getattr(sub, "premium_topup_used_bytes", 0) or 0)
|
||||
+ (getattr(sub, "premium_bonus_bytes", 0) or 0)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _traffic_snapshot(used: Any, limit: Any) -> dict:
|
||||
used_value = max(0, int(used or 0))
|
||||
limit_value = max(0, int(limit or 0))
|
||||
percent = round((used_value / limit_value) * 100, 2) if limit_value else 0
|
||||
left = max(0, limit_value - used_value) if limit_value else 0
|
||||
return {
|
||||
"used_bytes": used_value,
|
||||
"limit_bytes": limit_value,
|
||||
"percent": percent,
|
||||
"left_bytes": left,
|
||||
}
|
||||
@@ -336,6 +336,13 @@ class Settings(BaseSettings):
|
||||
description="Log updates/events triggered by users from ADMIN_IDS.",
|
||||
)
|
||||
|
||||
SUPPORT_TICKETS_ENABLED: bool = Field(default=True)
|
||||
SUPPORT_TICKET_MAX_BODY_LENGTH: int = Field(default=4000)
|
||||
SUPPORT_TICKET_MAX_SUBJECT_LENGTH: int = Field(default=160)
|
||||
SUPPORT_TICKET_RATE_LIMIT_PER_HOUR: int = Field(default=5)
|
||||
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED: bool = Field(default=False)
|
||||
SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS: int = Field(default=5 * 60)
|
||||
SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS: int = Field(default=30 * 60)
|
||||
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
|
||||
|
||||
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
|
||||
@@ -805,6 +812,9 @@ class Settings(BaseSettings):
|
||||
LOG_THREAD_ID: Optional[int] = Field(
|
||||
default=None, description="Thread ID for supergroup messages (optional)"
|
||||
)
|
||||
LOG_SUPPORT_THREAD_ID: Optional[int] = Field(
|
||||
default=None, description="Thread ID for support ticket log messages"
|
||||
)
|
||||
|
||||
@field_validator("LOG_LEVEL", mode="before")
|
||||
@classmethod
|
||||
@@ -835,7 +845,7 @@ class Settings(BaseSettings):
|
||||
return v
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
@field_validator("LOG_CHAT_ID", "LOG_THREAD_ID", mode="before")
|
||||
@field_validator("LOG_CHAT_ID", "LOG_THREAD_ID", "LOG_SUPPORT_THREAD_ID", mode="before")
|
||||
@classmethod
|
||||
def validate_optional_int_fields(cls, v):
|
||||
"""Convert empty strings to None for optional integer fields"""
|
||||
@@ -890,6 +900,7 @@ class Settings(BaseSettings):
|
||||
LOG_SUSPICIOUS_ACTIVITY: bool = Field(
|
||||
default=True, description="Send notifications for suspicious promo attempts"
|
||||
)
|
||||
LOG_SUPPORT: bool = Field(default=True, description="Send support ticket notifications")
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True
|
||||
|
||||
@@ -7,6 +7,7 @@ from . import (
|
||||
promo_code_dal,
|
||||
security_dal,
|
||||
subscription_dal,
|
||||
support_dal,
|
||||
user_billing_dal,
|
||||
user_dal,
|
||||
)
|
||||
@@ -22,4 +23,5 @@ __all__ = (
|
||||
"ad_dal",
|
||||
"security_dal",
|
||||
"app_settings_dal",
|
||||
"support_dal",
|
||||
)
|
||||
|
||||
@@ -37,6 +37,17 @@ async def get_all_overrides(session: AsyncSession) -> Dict[str, Any]:
|
||||
return {row.key: _decode(row.value) for row in rows}
|
||||
|
||||
|
||||
async def get_override_value(session: AsyncSession, key: str) -> Tuple[bool, Any]:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(AppSettingOverride).where(AppSettingOverride.key == key).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return False, None
|
||||
return True, _decode(row.value)
|
||||
|
||||
|
||||
async def get_overrides_with_meta(session: AsyncSession) -> List[Dict[str, Any]]:
|
||||
rows = (await session.execute(select(AppSettingOverride))).scalars().all()
|
||||
items: List[Dict[str, Any]] = []
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import and_, case, desc, func, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..models import SupportTicket, SupportTicketMessage, User
|
||||
|
||||
ACTIVE_STATUSES = {"open", "awaiting_user", "awaiting_admin"}
|
||||
CLOSED_STATUSES = {"resolved", "closed"}
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _status_condition(status: Optional[str]):
|
||||
normalized = (status or "").strip().lower()
|
||||
if not normalized or normalized in {"all", "any"}:
|
||||
return None
|
||||
if normalized == "active":
|
||||
return SupportTicket.status.in_(ACTIVE_STATUSES)
|
||||
if normalized == "closed":
|
||||
return SupportTicket.status.in_(CLOSED_STATUSES)
|
||||
return SupportTicket.status == normalized
|
||||
|
||||
|
||||
async def create_ticket(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
subject: str,
|
||||
category: str,
|
||||
priority: str,
|
||||
first_message_body: str,
|
||||
) -> SupportTicket:
|
||||
now = datetime.now(timezone.utc)
|
||||
ticket = SupportTicket(
|
||||
user_id=user_id,
|
||||
subject=subject,
|
||||
category=category,
|
||||
priority=priority,
|
||||
status="awaiting_admin",
|
||||
last_message_at=now,
|
||||
last_message_role="user",
|
||||
unread_admin_count=1,
|
||||
unread_user_count=0,
|
||||
admin_last_notified_at=now,
|
||||
admin_last_emailed_at=now,
|
||||
)
|
||||
session.add(ticket)
|
||||
await session.flush()
|
||||
message = SupportTicketMessage(
|
||||
ticket_id=ticket.ticket_id,
|
||||
author_role="user",
|
||||
author_user_id=user_id,
|
||||
body=first_message_body,
|
||||
is_internal_note=False,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(message)
|
||||
await session.flush()
|
||||
await session.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
|
||||
async def add_message(
|
||||
session: AsyncSession,
|
||||
ticket_id: int,
|
||||
author_role: str,
|
||||
author_user_id: Optional[int],
|
||||
body: str,
|
||||
is_internal_note: bool = False,
|
||||
) -> Optional[SupportTicketMessage]:
|
||||
stmt = select(SupportTicket).where(SupportTicket.ticket_id == ticket_id).with_for_update()
|
||||
result = await session.execute(stmt)
|
||||
ticket = result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
return None
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
message = SupportTicketMessage(
|
||||
ticket_id=ticket_id,
|
||||
author_role=author_role,
|
||||
author_user_id=author_user_id,
|
||||
body=body,
|
||||
is_internal_note=bool(is_internal_note),
|
||||
created_at=now,
|
||||
)
|
||||
session.add(message)
|
||||
|
||||
ticket.last_message_at = now
|
||||
ticket.last_message_role = author_role
|
||||
ticket.updated_at = now
|
||||
if author_role == "user":
|
||||
ticket.unread_admin_count = int(ticket.unread_admin_count or 0) + 1
|
||||
if ticket.status not in CLOSED_STATUSES:
|
||||
ticket.status = "awaiting_admin"
|
||||
elif author_role == "admin" and not is_internal_note:
|
||||
ticket.unread_user_count = int(ticket.unread_user_count or 0) + 1
|
||||
if ticket.status not in CLOSED_STATUSES:
|
||||
ticket.status = "awaiting_user"
|
||||
|
||||
await session.flush()
|
||||
await session.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
async def record_admin_notification(
|
||||
session: AsyncSession,
|
||||
ticket_id: int,
|
||||
*,
|
||||
notified_at: Optional[datetime] = None,
|
||||
emailed_at: Optional[datetime] = None,
|
||||
) -> None:
|
||||
values = {}
|
||||
if notified_at is not None:
|
||||
values["admin_last_notified_at"] = notified_at
|
||||
if emailed_at is not None:
|
||||
values["admin_last_emailed_at"] = emailed_at
|
||||
if not values:
|
||||
return
|
||||
await session.execute(
|
||||
update(SupportTicket).where(SupportTicket.ticket_id == ticket_id).values(**values)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def get_ticket(
|
||||
session: AsyncSession,
|
||||
ticket_id: int,
|
||||
*,
|
||||
include_internal: bool = False,
|
||||
) -> tuple[Optional[SupportTicket], list[SupportTicketMessage]]:
|
||||
stmt = (
|
||||
select(SupportTicket)
|
||||
.where(SupportTicket.ticket_id == ticket_id)
|
||||
.options(selectinload(SupportTicket.user))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
ticket = result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
return None, []
|
||||
|
||||
msg_stmt = select(SupportTicketMessage).where(SupportTicketMessage.ticket_id == ticket_id)
|
||||
if not include_internal:
|
||||
msg_stmt = msg_stmt.where(SupportTicketMessage.is_internal_note.is_(False))
|
||||
msg_stmt = msg_stmt.order_by(
|
||||
SupportTicketMessage.created_at.asc(),
|
||||
SupportTicketMessage.message_id.asc(),
|
||||
)
|
||||
msg_result = await session.execute(msg_stmt)
|
||||
return ticket, list(msg_result.scalars().all())
|
||||
|
||||
|
||||
async def list_user_tickets(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
status_filter: Optional[str] = None,
|
||||
) -> list[SupportTicket]:
|
||||
stmt = select(SupportTicket).where(SupportTicket.user_id == user_id)
|
||||
status_cond = _status_condition(status_filter)
|
||||
if status_cond is not None:
|
||||
stmt = stmt.where(status_cond)
|
||||
stmt = (
|
||||
stmt.order_by(desc(SupportTicket.last_message_at), desc(SupportTicket.ticket_id))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_admin_tickets(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
assigned_admin_id: Optional[int] = None,
|
||||
search: Optional[str] = None,
|
||||
sort: str = "updated_desc",
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[SupportTicket]:
|
||||
stmt = select(SupportTicket).join(User, User.user_id == SupportTicket.user_id)
|
||||
status_cond = _status_condition(status)
|
||||
if status_cond is not None:
|
||||
stmt = stmt.where(status_cond)
|
||||
if priority:
|
||||
stmt = stmt.where(SupportTicket.priority == priority)
|
||||
if category:
|
||||
stmt = stmt.where(SupportTicket.category == category)
|
||||
if assigned_admin_id is not None:
|
||||
stmt = stmt.where(SupportTicket.assigned_admin_id == assigned_admin_id)
|
||||
if search:
|
||||
pattern = f"%{search.strip().lower()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
func.lower(SupportTicket.subject).like(pattern),
|
||||
func.lower(User.username).like(pattern),
|
||||
func.lower(User.first_name).like(pattern),
|
||||
func.lower(User.email).like(pattern),
|
||||
)
|
||||
)
|
||||
priority_rank = case(
|
||||
(SupportTicket.priority == "urgent", 4),
|
||||
(SupportTicket.priority == "high", 3),
|
||||
(SupportTicket.priority == "normal", 2),
|
||||
(SupportTicket.priority == "low", 1),
|
||||
else_=0,
|
||||
)
|
||||
sort_key = (sort or "updated_desc").strip().lower()
|
||||
sort_map = {
|
||||
"updated_desc": (SupportTicket.last_message_at.desc().nullslast(),),
|
||||
"updated_asc": (SupportTicket.last_message_at.asc().nullslast(),),
|
||||
"created_desc": (SupportTicket.created_at.desc().nullslast(),),
|
||||
"created_asc": (SupportTicket.created_at.asc().nullslast(),),
|
||||
"importance_desc": (
|
||||
priority_rank.desc(),
|
||||
SupportTicket.last_message_at.desc().nullslast(),
|
||||
),
|
||||
"importance_asc": (
|
||||
priority_rank.asc(),
|
||||
SupportTicket.last_message_at.desc().nullslast(),
|
||||
),
|
||||
}
|
||||
order_by = sort_map.get(sort_key, sort_map["updated_desc"])
|
||||
stmt = stmt.options(selectinload(SupportTicket.user)).order_by(
|
||||
*order_by,
|
||||
desc(SupportTicket.ticket_id),
|
||||
)
|
||||
stmt = stmt.limit(limit).offset(offset)
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().unique().all())
|
||||
|
||||
|
||||
async def user_ticket_counts(session: AsyncSession, user_id: int) -> dict:
|
||||
stmt = (
|
||||
select(SupportTicket.status, func.count())
|
||||
.where(SupportTicket.user_id == user_id)
|
||||
.group_by(SupportTicket.status)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
by_status = {str(status): int(count or 0) for status, count in result.all()}
|
||||
active = sum(by_status.get(status, 0) for status in ACTIVE_STATUSES)
|
||||
closed = sum(by_status.get(status, 0) for status in CLOSED_STATUSES)
|
||||
return {
|
||||
**by_status,
|
||||
"active": active,
|
||||
"closed": closed,
|
||||
"total": active + closed,
|
||||
}
|
||||
|
||||
|
||||
async def mark_read(session: AsyncSession, ticket_id: int, role: str) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
if role == "user":
|
||||
await session.execute(
|
||||
update(SupportTicket)
|
||||
.where(SupportTicket.ticket_id == ticket_id)
|
||||
.values(unread_user_count=0, updated_at=now)
|
||||
)
|
||||
await session.execute(
|
||||
update(SupportTicketMessage)
|
||||
.where(
|
||||
and_(
|
||||
SupportTicketMessage.ticket_id == ticket_id,
|
||||
SupportTicketMessage.author_role == "admin",
|
||||
SupportTicketMessage.is_internal_note.is_(False),
|
||||
SupportTicketMessage.read_by_user_at.is_(None),
|
||||
)
|
||||
)
|
||||
.values(read_by_user_at=now)
|
||||
)
|
||||
elif role == "admin":
|
||||
await session.execute(
|
||||
update(SupportTicket)
|
||||
.where(SupportTicket.ticket_id == ticket_id)
|
||||
.values(unread_admin_count=0, updated_at=now)
|
||||
)
|
||||
await session.execute(
|
||||
update(SupportTicketMessage)
|
||||
.where(
|
||||
and_(
|
||||
SupportTicketMessage.ticket_id == ticket_id,
|
||||
SupportTicketMessage.author_role == "user",
|
||||
SupportTicketMessage.read_by_admin_at.is_(None),
|
||||
)
|
||||
)
|
||||
.values(read_by_admin_at=now)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_ticket(
|
||||
session: AsyncSession,
|
||||
ticket_id: int,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
assigned_admin_id: object = _UNSET,
|
||||
closed_by_admin_id: Optional[int] = None,
|
||||
) -> Optional[SupportTicket]:
|
||||
ticket = await session.get(SupportTicket, ticket_id)
|
||||
if not ticket:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
if status is not None:
|
||||
ticket.status = status
|
||||
if status == "closed":
|
||||
ticket.closed_at = now
|
||||
ticket.closed_by_admin_id = closed_by_admin_id
|
||||
elif status != "closed":
|
||||
ticket.closed_at = None
|
||||
ticket.closed_by_admin_id = None
|
||||
if priority is not None:
|
||||
ticket.priority = priority
|
||||
if category is not None:
|
||||
ticket.category = category
|
||||
if assigned_admin_id is not _UNSET:
|
||||
ticket.assigned_admin_id = assigned_admin_id
|
||||
ticket.updated_at = now
|
||||
await session.flush()
|
||||
await session.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
|
||||
async def count_user_unread(session: AsyncSession, user_id: int) -> int:
|
||||
stmt = select(func.coalesce(func.sum(SupportTicket.unread_user_count), 0)).where(
|
||||
SupportTicket.user_id == user_id
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
async def admin_stats(session: AsyncSession) -> dict:
|
||||
status_result = await session.execute(
|
||||
select(SupportTicket.status, func.count()).group_by(SupportTicket.status)
|
||||
)
|
||||
by_status = {str(status): int(count or 0) for status, count in status_result.all()}
|
||||
unread_result = await session.execute(
|
||||
select(func.coalesce(func.sum(SupportTicket.unread_admin_count), 0))
|
||||
)
|
||||
active = sum(by_status.get(status, 0) for status in ACTIVE_STATUSES)
|
||||
closed = sum(by_status.get(status, 0) for status in CLOSED_STATUSES)
|
||||
return {
|
||||
**by_status,
|
||||
"active": active,
|
||||
"closed": closed,
|
||||
"total": active + closed,
|
||||
"open": by_status.get("open", 0),
|
||||
"awaiting_admin": by_status.get("awaiting_admin", 0),
|
||||
"awaiting_user": by_status.get("awaiting_user", 0),
|
||||
"total_unread_admin": int(unread_result.scalar_one() or 0),
|
||||
}
|
||||
|
||||
|
||||
async def count_recent_tickets_for_user(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
window_seconds: int,
|
||||
) -> int:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(seconds=max(1, int(window_seconds)))
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(SupportTicket)
|
||||
.where(SupportTicket.user_id == user_id, SupportTicket.created_at >= cutoff)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return int(result.scalar_one() or 0)
|
||||
@@ -754,6 +754,126 @@ def _migration_0023_add_email_password_auth_fields(connection: Connection) -> No
|
||||
connection.execute(text("ALTER TABLE users ADD COLUMN password_set_at TIMESTAMPTZ"))
|
||||
|
||||
|
||||
def _migration_0024_add_support_tickets(connection: Connection) -> None:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS support_tickets (
|
||||
ticket_id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(user_id),
|
||||
subject VARCHAR(160) NOT NULL,
|
||||
category VARCHAR(32) NOT NULL DEFAULT 'other',
|
||||
priority VARCHAR(16) NOT NULL DEFAULT 'normal',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'open',
|
||||
assigned_admin_id BIGINT NULL,
|
||||
last_message_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_message_role VARCHAR(16) NULL,
|
||||
unread_user_count INTEGER NOT NULL DEFAULT 0,
|
||||
unread_admin_count INTEGER NOT NULL DEFAULT 0,
|
||||
admin_last_notified_at TIMESTAMPTZ NULL,
|
||||
admin_last_emailed_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NULL,
|
||||
closed_at TIMESTAMPTZ NULL,
|
||||
closed_by_admin_id BIGINT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS support_ticket_messages (
|
||||
message_id SERIAL PRIMARY KEY,
|
||||
ticket_id INTEGER NOT NULL REFERENCES support_tickets(ticket_id) ON DELETE CASCADE,
|
||||
author_role VARCHAR(16) NOT NULL,
|
||||
author_user_id BIGINT NULL REFERENCES users(user_id),
|
||||
body TEXT NOT NULL,
|
||||
is_internal_note BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
read_by_user_at TIMESTAMPTZ NULL,
|
||||
read_by_admin_at TIMESTAMPTZ NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
inspector = inspect(connection)
|
||||
ticket_columns: Set[str] = {col["name"] for col in inspector.get_columns("support_tickets")}
|
||||
ticket_column_sql = {
|
||||
"user_id": "BIGINT NOT NULL REFERENCES users(user_id)",
|
||||
"subject": "VARCHAR(160) NOT NULL DEFAULT ''",
|
||||
"category": "VARCHAR(32) NOT NULL DEFAULT 'other'",
|
||||
"priority": "VARCHAR(16) NOT NULL DEFAULT 'normal'",
|
||||
"status": "VARCHAR(24) NOT NULL DEFAULT 'open'",
|
||||
"assigned_admin_id": "BIGINT NULL",
|
||||
"last_message_at": "TIMESTAMPTZ DEFAULT NOW()",
|
||||
"last_message_role": "VARCHAR(16) NULL",
|
||||
"unread_user_count": "INTEGER NOT NULL DEFAULT 0",
|
||||
"unread_admin_count": "INTEGER NOT NULL DEFAULT 0",
|
||||
"admin_last_notified_at": "TIMESTAMPTZ NULL",
|
||||
"admin_last_emailed_at": "TIMESTAMPTZ NULL",
|
||||
"created_at": "TIMESTAMPTZ DEFAULT NOW()",
|
||||
"updated_at": "TIMESTAMPTZ NULL",
|
||||
"closed_at": "TIMESTAMPTZ NULL",
|
||||
"closed_by_admin_id": "BIGINT NULL",
|
||||
}
|
||||
for column, definition in ticket_column_sql.items():
|
||||
if column not in ticket_columns:
|
||||
connection.execute(
|
||||
text(f"ALTER TABLE support_tickets ADD COLUMN {column} {definition}")
|
||||
)
|
||||
|
||||
message_columns: Set[str] = {
|
||||
col["name"] for col in inspector.get_columns("support_ticket_messages")
|
||||
}
|
||||
message_column_sql = {
|
||||
"ticket_id": "INTEGER NOT NULL REFERENCES support_tickets(ticket_id) ON DELETE CASCADE",
|
||||
"author_role": "VARCHAR(16) NOT NULL DEFAULT 'user'",
|
||||
"author_user_id": "BIGINT NULL REFERENCES users(user_id)",
|
||||
"body": "TEXT NOT NULL DEFAULT ''",
|
||||
"is_internal_note": "BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
"created_at": "TIMESTAMPTZ DEFAULT NOW()",
|
||||
"read_by_user_at": "TIMESTAMPTZ NULL",
|
||||
"read_by_admin_at": "TIMESTAMPTZ NULL",
|
||||
}
|
||||
for column, definition in message_column_sql.items():
|
||||
if column not in message_columns:
|
||||
connection.execute(
|
||||
text(f"ALTER TABLE support_ticket_messages ADD COLUMN {column} {definition}")
|
||||
)
|
||||
|
||||
index_statements = [
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_tickets_user_id ON support_tickets (user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_tickets_category ON support_tickets (category)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_tickets_priority ON support_tickets (priority)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_tickets_status ON support_tickets (status)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_tickets_assigned_admin_id ON support_tickets (assigned_admin_id)", # noqa: E501
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_tickets_last_message_at ON support_tickets (last_message_at)", # noqa: E501
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_tickets_status_last_msg ON support_tickets (status, last_message_at)", # noqa: E501
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_ticket_id ON support_ticket_messages (ticket_id)", # noqa: E501
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_author_user_id ON support_ticket_messages (author_user_id)", # noqa: E501
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_is_internal_note ON support_ticket_messages (is_internal_note)", # noqa: E501
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_created_at ON support_ticket_messages (created_at)", # noqa: E501
|
||||
]
|
||||
for stmt in index_statements:
|
||||
connection.execute(text(stmt))
|
||||
|
||||
|
||||
def _migration_0025_add_support_notification_timestamps(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
ticket_columns: Set[str] = {col["name"] for col in inspector.get_columns("support_tickets")}
|
||||
column_sql = {
|
||||
"admin_last_notified_at": "TIMESTAMPTZ NULL",
|
||||
"admin_last_emailed_at": "TIMESTAMPTZ NULL",
|
||||
}
|
||||
for column, definition in column_sql.items():
|
||||
if column not in ticket_columns:
|
||||
connection.execute(
|
||||
text(f"ALTER TABLE support_tickets ADD COLUMN {column} {definition}")
|
||||
)
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
@@ -881,6 +1001,16 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Store hashed passwords for optional email password login",
|
||||
upgrade=_migration_0023_add_email_password_auth_fields,
|
||||
),
|
||||
Migration(
|
||||
id="0024_add_support_tickets",
|
||||
description="Add support ticket inbox and messages",
|
||||
upgrade=_migration_0024_add_support_tickets,
|
||||
),
|
||||
Migration(
|
||||
id="0025_add_support_notification_timestamps",
|
||||
description="Track support ticket admin notification cooldown timestamps",
|
||||
upgrade=_migration_0025_add_support_notification_timestamps,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -374,6 +374,62 @@ class MessageLog(Base):
|
||||
)
|
||||
|
||||
|
||||
class SupportTicket(Base):
|
||||
__tablename__ = "support_tickets"
|
||||
|
||||
ticket_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||
subject = Column(String(160), nullable=False)
|
||||
category = Column(String(32), nullable=False, default="other", index=True)
|
||||
priority = Column(String(16), nullable=False, default="normal", index=True)
|
||||
status = Column(String(24), nullable=False, default="open", index=True)
|
||||
assigned_admin_id = Column(BigInteger, nullable=True, index=True)
|
||||
last_message_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
last_message_role = Column(String(16), nullable=True)
|
||||
unread_user_count = Column(Integer, nullable=False, default=0)
|
||||
unread_admin_count = Column(Integer, nullable=False, default=0)
|
||||
admin_last_notified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
admin_last_emailed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
closed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
closed_by_admin_id = Column(BigInteger, nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
messages = relationship(
|
||||
"SupportTicketMessage",
|
||||
back_populates="ticket",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_support_tickets_status_last_msg", "status", "last_message_at"),
|
||||
)
|
||||
|
||||
|
||||
class SupportTicketMessage(Base):
|
||||
__tablename__ = "support_ticket_messages"
|
||||
|
||||
message_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
ticket_id = Column(
|
||||
Integer,
|
||||
ForeignKey("support_tickets.ticket_id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
author_role = Column(String(16), nullable=False)
|
||||
author_user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True, index=True)
|
||||
body = Column(Text, nullable=False)
|
||||
is_internal_note = Column(Boolean, nullable=False, default=False, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
read_by_user_at = Column(DateTime(timezone=True), nullable=True)
|
||||
read_by_admin_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
ticket = relationship("SupportTicket", back_populates="messages")
|
||||
author_user = relationship("User")
|
||||
|
||||
|
||||
class PanelSyncStatus(Base):
|
||||
__tablename__ = "panel_sync_status"
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ COPY --from=version-builder /build-tag /build-tag
|
||||
COPY --from=version-builder /build-commit /build-commit
|
||||
COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.js /usr/share/nginx/html/subscription_webapp.js
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js /usr/share/nginx/html/
|
||||
|
||||
@@ -112,9 +113,17 @@ RUN set -eu; \
|
||||
find /docker-entrypoint.d -type f -name '*.sh' -exec sed -i 's/\r$//' {} +; \
|
||||
HASHED=$(ls /usr/share/nginx/html/subscription_webapp.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \
|
||||
JS_NAME="${HASHED:-subscription_webapp.js}"; \
|
||||
CSS_NAME="subscription_webapp.css"; \
|
||||
for candidate in /usr/share/nginx/html/subscription_webapp.*.css; do \
|
||||
name="$(basename "$candidate")"; \
|
||||
case "$name" in \
|
||||
subscription_webapp.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].css) CSS_NAME="$name" ;; \
|
||||
esac; \
|
||||
done; \
|
||||
sed -i \
|
||||
-e '/WEBAPP_I18N_SCRIPT/d' \
|
||||
-e '/WEBAPP_CONFIG_SCRIPT/d' \
|
||||
-e "s|href=\"/subscription_webapp.css\"|href=\"/${CSS_NAME}\"|" \
|
||||
-e "/WEBAPP_JS_SCRIPT/c\\ <script src=\"/${JS_NAME}\" type=\"module\"></script>" \
|
||||
-e '/WEBAPP_DEV_MOCK_START/d' \
|
||||
-e '/WEBAPP_DEV_MOCK_END/d' \
|
||||
|
||||
@@ -4,6 +4,19 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 1024;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_types
|
||||
application/javascript
|
||||
application/json
|
||||
image/svg+xml
|
||||
text/css
|
||||
text/javascript
|
||||
text/plain;
|
||||
|
||||
location = /health {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
@@ -45,6 +58,18 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~* ^/subscription_webapp\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ {
|
||||
expires off;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* ^/subscription_webapp\.(css|js)$ {
|
||||
expires off;
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
@@ -52,6 +77,7 @@ server {
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,15 @@ const sourcePath = path.join(
|
||||
"templates",
|
||||
"subscription_webapp.js"
|
||||
);
|
||||
const sourceCssPath = path.join(
|
||||
repoRoot,
|
||||
"backend",
|
||||
"bot",
|
||||
"app",
|
||||
"web",
|
||||
"templates",
|
||||
"subscription_webapp.css"
|
||||
);
|
||||
|
||||
function normalizeLineEndings(value) {
|
||||
return value.replace(/\r\n/g, "\n");
|
||||
@@ -50,16 +59,11 @@ function stripFallbackI18n(source) {
|
||||
);
|
||||
}
|
||||
|
||||
async function removeOldMinifiedAssets(assetDir, keepName) {
|
||||
async function removeOldHashedAssets(assetDir, pattern, keepName) {
|
||||
const entries = await readdir(assetDir, { withFileTypes: true });
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() &&
|
||||
/^subscription_webapp\.min\.[0-9a-f]{8}\.js$/.test(entry.name) &&
|
||||
entry.name !== keepName
|
||||
)
|
||||
.filter((entry) => entry.isFile() && pattern.test(entry.name) && entry.name !== keepName)
|
||||
.map((entry) => unlink(path.join(assetDir, entry.name)))
|
||||
);
|
||||
}
|
||||
@@ -84,11 +88,31 @@ async function main() {
|
||||
const hash = createHash("sha256").update(code, "utf8").digest("hex").slice(0, 8);
|
||||
const outputPath = path.join(path.dirname(sourcePath), `subscription_webapp.min.${hash}.js`);
|
||||
|
||||
await removeOldMinifiedAssets(path.dirname(sourcePath), path.basename(outputPath));
|
||||
await removeOldHashedAssets(
|
||||
path.dirname(sourcePath),
|
||||
/^subscription_webapp\.min\.[0-9a-f]{8}\.js$/,
|
||||
path.basename(outputPath)
|
||||
);
|
||||
await writeFile(outputPath, code, "utf8");
|
||||
console.log(
|
||||
`Wrote ${path.relative(repoRoot, outputPath)} (${Buffer.byteLength(code, "utf8")} bytes)`
|
||||
);
|
||||
|
||||
const css = await readFile(sourceCssPath, "utf8");
|
||||
const cssHash = createHash("sha256").update(css, "utf8").digest("hex").slice(0, 8);
|
||||
const cssOutputPath = path.join(
|
||||
path.dirname(sourceCssPath),
|
||||
`subscription_webapp.${cssHash}.css`
|
||||
);
|
||||
await removeOldHashedAssets(
|
||||
path.dirname(sourceCssPath),
|
||||
/^subscription_webapp\.[0-9a-f]{8}\.css$/,
|
||||
path.basename(cssOutputPath)
|
||||
);
|
||||
await writeFile(cssOutputPath, css, "utf8");
|
||||
console.log(
|
||||
`Wrote ${path.relative(repoRoot, cssOutputPath)} (${Buffer.byteLength(css, "utf8")} bytes)`
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
|
||||
+125
-8
@@ -3,6 +3,7 @@
|
||||
import { createAuthStore } from "./lib/webapp/stores/authStore.js";
|
||||
import { createBillingStore } from "./lib/webapp/stores/billingStore.js";
|
||||
import { createDevicesStore } from "./lib/webapp/stores/devicesStore.js";
|
||||
import { createSupportStore } from "./lib/webapp/stores/supportStore.js";
|
||||
import { createAccountStore } from "./lib/webapp/stores/accountStore.js";
|
||||
import { Tooltip } from "$components/ui/primitives.js";
|
||||
|
||||
@@ -17,6 +18,8 @@
|
||||
import HomeScreen from "./webapp/screens/HomeScreen.svelte";
|
||||
import InviteScreen from "./webapp/screens/InviteScreen.svelte";
|
||||
import SettingsScreen from "./webapp/screens/SettingsScreen.svelte";
|
||||
import SupportScreen from "./webapp/screens/SupportScreen.svelte";
|
||||
import SupportTicketScreen from "./webapp/screens/SupportTicketScreen.svelte";
|
||||
|
||||
import {
|
||||
LANGUAGE_FLAGS,
|
||||
@@ -70,6 +73,7 @@
|
||||
adminUserIdFromPath,
|
||||
normalizeSection,
|
||||
sectionFromPath,
|
||||
supportTicketIdFromPath,
|
||||
syncSectionPath,
|
||||
} from "./lib/webapp/routes.js";
|
||||
|
||||
@@ -114,6 +118,8 @@
|
||||
let token = MOCK ? "local-preview" : "";
|
||||
let csrfToken = MOCK ? "" : readCookie(CSRF_COOKIE_NAME) || "";
|
||||
let scrollLockApplied = false;
|
||||
let adminI18nLoaded = false;
|
||||
let adminI18nPromise = null;
|
||||
let tg = null;
|
||||
const telegramSdk = createTelegramSdk({
|
||||
scriptUrl: TELEGRAM_WEBAPP_SCRIPT_URL,
|
||||
@@ -170,6 +176,7 @@
|
||||
tg,
|
||||
});
|
||||
const devicesStore = createDevicesStore({ api, t, showToast });
|
||||
const supportStore = createSupportStore({ api, t, showToast });
|
||||
const accountStore = createAccountStore({
|
||||
api,
|
||||
publicApi,
|
||||
@@ -194,6 +201,7 @@
|
||||
setContext("authStore", authStore);
|
||||
setContext("billingStore", billingStore);
|
||||
setContext("devicesStore", devicesStore);
|
||||
setContext("supportStore", supportStore);
|
||||
setContext("accountStore", accountStore);
|
||||
|
||||
$: ({
|
||||
@@ -233,6 +241,11 @@
|
||||
deviceToDisconnect,
|
||||
deviceDisconnectBusy,
|
||||
} = $devicesStore);
|
||||
$: ({
|
||||
unreadCount: supportUnreadCount,
|
||||
unreadLoading: supportUnreadLoading,
|
||||
unreadLoaded: supportUnreadLoaded,
|
||||
} = $supportStore);
|
||||
$: ({
|
||||
linkEmailOpen,
|
||||
linkEmailBusy,
|
||||
@@ -278,6 +291,7 @@
|
||||
: []
|
||||
: plans;
|
||||
$: devicesEnabled = Boolean(appSettings?.my_devices_enabled);
|
||||
$: supportEnabled = Boolean(appSettings?.support_tickets_enabled ?? true);
|
||||
$: subscription = data?.subscription || DEV_MOCK.data.subscription;
|
||||
$: hasActiveTariffSubscription = Boolean(
|
||||
tariffMode && subscription?.active && subscription?.tariff_key
|
||||
@@ -467,13 +481,28 @@
|
||||
}
|
||||
if (mode === "app") {
|
||||
if (section === "admin" && isAdmin) {
|
||||
screen = "admin";
|
||||
const pathAtStart = window.location.pathname;
|
||||
void ensureI18nScope("admin").finally(() => {
|
||||
if (sectionFromPath(window.location.pathname) !== "admin") return;
|
||||
if (window.location.pathname !== pathAtStart) return;
|
||||
activeTab = "settings";
|
||||
screen = "admin";
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nextSection = section === "devices" && !devicesEnabled ? "home" : section;
|
||||
const nextSection =
|
||||
section === "devices" && !devicesEnabled
|
||||
? "home"
|
||||
: section === "support" && !supportEnabled
|
||||
? "home"
|
||||
: section;
|
||||
activeTab = nextSection;
|
||||
screen = nextSection;
|
||||
if (nextSection === "devices") devicesStore.loadDevices(devicesEnabled);
|
||||
if (nextSection === "support") {
|
||||
supportStore.loadList();
|
||||
supportStore.startPolling({ includeList: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
@@ -486,6 +515,7 @@
|
||||
authStore.clearCooldownTimer();
|
||||
accountStore.clearLinkEmailResendTimer();
|
||||
accountStore.clearSetPasswordResendTimer();
|
||||
supportStore.closePolling();
|
||||
clearLanguageClickGuard();
|
||||
syncBodyScrollLock(false);
|
||||
};
|
||||
@@ -552,6 +582,29 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureI18nScope(scope) {
|
||||
if (MOCK || scope !== "admin" || adminI18nLoaded) return;
|
||||
if (adminI18nPromise) return adminI18nPromise;
|
||||
const apiBase = String(CFG.apiBase || "/api").replace(/\/+$/, "");
|
||||
adminI18nPromise = fetch(`${apiBase}/i18n?scope=admin`, {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((payload) => {
|
||||
if (!payload?.ok || !payload.i18n) return;
|
||||
i18n.mergeMessages(payload.i18n);
|
||||
adminI18nLoaded = true;
|
||||
})
|
||||
.catch((_error) => {
|
||||
void _error;
|
||||
})
|
||||
.finally(() => {
|
||||
adminI18nPromise = null;
|
||||
});
|
||||
return adminI18nPromise;
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
await runWebappBoot({
|
||||
MOCK,
|
||||
@@ -640,17 +693,47 @@
|
||||
: sectionFromPath(window.location.pathname);
|
||||
if (section === "admin" && !payload.user?.is_admin) section = "settings";
|
||||
if (section === "devices" && !payload.settings?.my_devices_enabled) section = "home";
|
||||
if (section === "support" && payload.settings?.support_tickets_enabled === false) {
|
||||
section = "home";
|
||||
}
|
||||
const initialAdminSection =
|
||||
section === "admin" ? adminSectionFromPath(window.location.pathname) : null;
|
||||
if (section === "admin" && payload.user?.is_admin) {
|
||||
await ensureI18nScope("admin");
|
||||
}
|
||||
const initialSupportTicketId =
|
||||
section === "support" ? supportTicketIdFromPath(window.location.pathname) : null;
|
||||
activeTab = section === "admin" ? "settings" : section;
|
||||
screen = section;
|
||||
mode = "app";
|
||||
syncSectionPath(
|
||||
section,
|
||||
true,
|
||||
section === "admin" ? adminSectionFromPath(window.location.pathname) : null
|
||||
);
|
||||
if (payload.settings?.support_tickets_enabled !== false) {
|
||||
if (typeof payload.support_unread_count !== "undefined") {
|
||||
supportStore.hydrateUnread(payload.support_unread_count);
|
||||
}
|
||||
void supportStore.refreshUnread();
|
||||
supportStore.startPolling({ includeList: false });
|
||||
}
|
||||
if (section === "support" && initialSupportTicketId) {
|
||||
const targetPath = `/support/${initialSupportTicketId}`;
|
||||
if (window.location.protocol !== "file:" && window.location.pathname !== targetPath) {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${targetPath}${window.location.search}${window.location.hash}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
syncSectionPath(section, true, initialAdminSection);
|
||||
}
|
||||
if (section === "devices" && payload.settings?.my_devices_enabled) {
|
||||
await devicesStore.loadDevices(true);
|
||||
}
|
||||
if (section === "support") {
|
||||
if (initialSupportTicketId)
|
||||
await supportStore.openTicket(initialSupportTicketId, { skipPush: true });
|
||||
else await supportStore.loadList();
|
||||
supportStore.startPolling({ includeList: true });
|
||||
}
|
||||
if (topupModalOpen) await billingStore.loadTopupOptions(topupKind);
|
||||
if (deviceTopupModalOpen) await billingStore.loadDeviceTopupOptions();
|
||||
if (changeModalOpen) await billingStore.loadTariffChangeOptions();
|
||||
@@ -846,6 +929,16 @@
|
||||
devicesStore.loadDevices(devicesEnabled);
|
||||
}
|
||||
|
||||
function goSupport() {
|
||||
if (!supportEnabled) return;
|
||||
billingStore.closePaymentModal();
|
||||
activeTab = "support";
|
||||
screen = "support";
|
||||
syncSectionPath("support");
|
||||
supportStore.loadList();
|
||||
supportStore.startPolling({ includeList: true });
|
||||
}
|
||||
|
||||
function defaultPaymentMethod() {
|
||||
return methods[0]?.id || "";
|
||||
}
|
||||
@@ -900,10 +993,11 @@
|
||||
syncSectionPath("settings");
|
||||
}
|
||||
|
||||
function openAdminPanel() {
|
||||
async function openAdminPanel() {
|
||||
if (!isAdmin) return;
|
||||
clearLanguageClickGuard();
|
||||
billingStore.closePaymentModal();
|
||||
await ensureI18nScope("admin");
|
||||
activeTab = "settings";
|
||||
screen = "admin";
|
||||
syncSectionPath("admin", false, adminSectionFromPath(window.location.pathname));
|
||||
@@ -1067,12 +1161,17 @@
|
||||
{brandTitle}
|
||||
{brand}
|
||||
{devicesEnabled}
|
||||
{supportEnabled}
|
||||
{supportUnreadCount}
|
||||
{supportUnreadLoading}
|
||||
{supportUnreadLoaded}
|
||||
{hasUnlinkedIdentity}
|
||||
{isAdmin}
|
||||
{openAdminPanel}
|
||||
{goDevices}
|
||||
{goHome}
|
||||
{goInvite}
|
||||
{goSupport}
|
||||
{goSettings}
|
||||
{t}
|
||||
>
|
||||
@@ -1131,6 +1230,24 @@
|
||||
{openDeviceTopupModal}
|
||||
{t}
|
||||
/>
|
||||
{:else if screen === "support"}
|
||||
{#if $supportStore.openedTicketId}
|
||||
<SupportTicketScreen
|
||||
maxBodyLength={appSettings?.support_ticket_max_body_length || 4000}
|
||||
{brand}
|
||||
userAvatarUrl={profileAvatarUrl}
|
||||
userInitials={telegramProfileName
|
||||
? telegramProfileName.slice(0, 2).toUpperCase()
|
||||
: "U"}
|
||||
{t}
|
||||
/>
|
||||
{:else}
|
||||
<SupportScreen
|
||||
maxSubjectLength={appSettings?.support_ticket_max_subject_length || 160}
|
||||
maxBodyLength={appSettings?.support_ticket_max_body_length || 4000}
|
||||
{t}
|
||||
/>
|
||||
{/if}
|
||||
{:else if screen === "settings"}
|
||||
<SettingsScreen
|
||||
{currentLang}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
FileText,
|
||||
Globe2,
|
||||
LayoutDashboard,
|
||||
LifeBuoy,
|
||||
Megaphone,
|
||||
Menu,
|
||||
Paintbrush,
|
||||
@@ -33,6 +34,7 @@
|
||||
import PromosSection from "./sections/PromosSection.svelte";
|
||||
import SettingsSection from "./sections/SettingsSection.svelte";
|
||||
import StatsSection from "./sections/StatsSection.svelte";
|
||||
import SupportSection from "./sections/SupportSection.svelte";
|
||||
import TariffEditorModal from "./sections/TariffEditorModal.svelte";
|
||||
import TariffsSection from "./sections/TariffsSection.svelte";
|
||||
import AppearanceSection from "./sections/AppearanceSection.svelte";
|
||||
@@ -45,6 +47,7 @@
|
||||
import { createPromosStore } from "../lib/admin/stores/promosStore.js";
|
||||
import { createSettingsStore } from "../lib/admin/stores/settingsStore.js";
|
||||
import { createStatsStore } from "../lib/admin/stores/statsStore.js";
|
||||
import { createAdminSupportStore } from "../lib/admin/stores/supportStore.js";
|
||||
import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js";
|
||||
import { createThemesStore } from "../lib/admin/stores/themesStore.js";
|
||||
import { createUsersStore } from "../lib/admin/stores/usersStore.js";
|
||||
@@ -113,6 +116,7 @@
|
||||
items: [
|
||||
{ id: "broadcast", label: at("nav_broadcast", {}, "Рассылка"), icon: Megaphone },
|
||||
{ id: "logs", label: at("nav_logs", {}, "Логи"), icon: FileText },
|
||||
{ id: "support", label: at("nav_support", {}, "Поддержка"), icon: LifeBuoy },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -159,6 +163,10 @@
|
||||
title: at("section_logs_title", {}, "Логи активности"),
|
||||
subtitle: at("section_logs_subtitle", {}, "События пользователей и админ-действия"),
|
||||
},
|
||||
support: {
|
||||
title: at("section_support_title", {}, "Поддержка"),
|
||||
subtitle: at("section_support_subtitle", {}, "Инбокс тикетов и ответы пользователям"),
|
||||
},
|
||||
tariffs: {
|
||||
title: at("section_tariffs_title", {}, "Тарифы"),
|
||||
subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"),
|
||||
@@ -209,6 +217,7 @@
|
||||
const promosStore = createPromosStore({ api, onToast: flash, at });
|
||||
const settingsStore = createSettingsStore({ api, onToast: flash, at });
|
||||
const statsStore = createStatsStore({ api, onToast: flash, at });
|
||||
const supportStore = createAdminSupportStore({ api, onToast: flash, at });
|
||||
const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at });
|
||||
const themesStore = createThemesStore({ api, onThemesSaved, flash, at });
|
||||
const usersStore = createUsersStore({ api, onToast: flash, at });
|
||||
@@ -219,12 +228,14 @@
|
||||
setContext("logsStore", logsStore);
|
||||
setContext("paymentsStore", paymentsStore);
|
||||
setContext("statsStore", statsStore);
|
||||
setContext("adminSupportStore", supportStore);
|
||||
setContext("settingsStore", settingsStore);
|
||||
setContext("usersStore", usersStore);
|
||||
setContext("tariffsStore", tariffsStore);
|
||||
setContext("themesStore", themesStore);
|
||||
|
||||
$: usersStore.setActive(active);
|
||||
$: supportStore.setActive(active);
|
||||
$: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length;
|
||||
$: syncBusy = $statsStore.syncBusy;
|
||||
$: settingsSaving = $settingsStore.settingsSaving;
|
||||
@@ -240,6 +251,7 @@
|
||||
if (active === next) return;
|
||||
active = next;
|
||||
usersStore.closeUser();
|
||||
supportStore.closeTicketView();
|
||||
onSectionChange(next);
|
||||
}
|
||||
|
||||
@@ -255,6 +267,12 @@
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
function readSupportTicketIdFromPath() {
|
||||
if (typeof window === "undefined") return null;
|
||||
const match = window.location.pathname.match(/^\/admin\/support\/(\d+)$/);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
function onPopState() {
|
||||
active = readSectionFromPath();
|
||||
sidebarOpen = false;
|
||||
@@ -266,6 +284,14 @@
|
||||
} else if ($usersStore.openedUser) {
|
||||
usersStore.closeUser({ skipPush: true });
|
||||
}
|
||||
const ticketId = readSupportTicketIdFromPath();
|
||||
if (active === "support" && ticketId) {
|
||||
if (!$supportStore.openedTicketId || $supportStore.openedTicketId !== ticketId) {
|
||||
supportStore.openTicket(ticketId, { skipPush: true });
|
||||
}
|
||||
} else if (active === "support" && $supportStore.openedTicketId) {
|
||||
supportStore.closeTicketView({ skipPush: true });
|
||||
}
|
||||
}
|
||||
|
||||
function exportPayments() {
|
||||
@@ -288,10 +314,7 @@
|
||||
}
|
||||
|
||||
function resolvedAvatarUrl(user) {
|
||||
return (
|
||||
userAvatarUrl(user) ||
|
||||
(!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : "")
|
||||
);
|
||||
return userAvatarUrl(user) || (user?.email ? gravatarCache.gravatarUrl(user.email) : "");
|
||||
}
|
||||
|
||||
function panelStatusBadge(user) {
|
||||
@@ -463,7 +486,13 @@
|
||||
>
|
||||
<svelte:component this={item.icon} size={16} />
|
||||
<span>{item.label}</span>
|
||||
<span></span>
|
||||
<span>
|
||||
{#if item.id === "support" && $supportStore.stats?.total_unread_admin}
|
||||
<AdminBadge variant="danger">
|
||||
<span class="numeric-badge-value">{$supportStore.stats.total_unread_admin}</span>
|
||||
</AdminBadge>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -647,6 +676,15 @@
|
||||
<LogsSection {at} {fmtDate} />
|
||||
{/if}
|
||||
|
||||
{#if active === "support"}
|
||||
<SupportSection
|
||||
{at}
|
||||
{brand}
|
||||
{resolvedAvatarUrl}
|
||||
initialTicketId={readSupportTicketIdFromPath()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if active === "tariffs"}
|
||||
<TariffsSection {at} {fmtMoney} />
|
||||
{/if}
|
||||
|
||||
@@ -532,7 +532,9 @@
|
||||
>
|
||||
<Switch.Thumb class="admin-switch-thumb" />
|
||||
</Switch.Root>
|
||||
<span>{at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}</span>
|
||||
<span
|
||||
>{at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
bind:this={faviconFileInput}
|
||||
@@ -697,8 +699,7 @@
|
||||
max="300"
|
||||
step="5"
|
||||
value={homeLogoScale(theme)}
|
||||
oninput={(event) =>
|
||||
setThemeHomeLogoScale(theme, event.currentTarget.value)}
|
||||
oninput={(event) => setThemeHomeLogoScale(theme, event.currentTarget.value)}
|
||||
/>
|
||||
%
|
||||
</span>
|
||||
|
||||
@@ -139,14 +139,15 @@
|
||||
|
||||
function sectionTitle(id) {
|
||||
const map = {
|
||||
general: at("settings_section_general", {}, "Общие"),
|
||||
appearance: at("settings_section_appearance", {}, "Внешний вид"),
|
||||
pricing: at("settings_section_pricing", {}, "Тарифы и цены"),
|
||||
payments: at("settings_section_payments", {}, "Платёжные системы"),
|
||||
trial: at("settings_section_trial", {}, "Триал"),
|
||||
referral: at("settings_section_referral", {}, "Реферальная программа"),
|
||||
notifications: at("settings_section_notifications", {}, "Уведомления"),
|
||||
devices: at("settings_section_devices", {}, "Устройства"),
|
||||
general: at("admin_settings_section_general", {}, "Общие"),
|
||||
appearance: at("admin_settings_section_appearance", {}, "Внешний вид"),
|
||||
pricing: at("admin_settings_section_pricing", {}, "Тарифы и цены"),
|
||||
payments: at("admin_settings_section_payments", {}, "Платёжные системы"),
|
||||
trial: at("admin_settings_section_trial", {}, "Триал"),
|
||||
referral: at("admin_settings_section_referral", {}, "Реферальная программа"),
|
||||
notifications: at("admin_settings_section_notifications", {}, "Уведомления"),
|
||||
support: at("admin_settings_section_support", {}, "Поддержка"),
|
||||
devices: at("admin_settings_section_devices", {}, "Устройства"),
|
||||
};
|
||||
return map[id] || id;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
<script>
|
||||
import { afterUpdate, getContext, onMount, tick } from "svelte";
|
||||
import {
|
||||
AdminButton,
|
||||
AdminSelect,
|
||||
SupportComposer,
|
||||
SupportInboxRow,
|
||||
SupportTicketHeader,
|
||||
SupportUserContextPanel,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
import { TicketMessageBubble } from "$components/patterns/webapp/index.js";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
import { Search } from "$components/ui/icons.js";
|
||||
import { ScrollArea, Skeleton } from "$components/ui/index.js";
|
||||
|
||||
export let at = (key) => key;
|
||||
export let initialTicketId = null;
|
||||
export let brand = {};
|
||||
export let resolvedAvatarUrl = () => "";
|
||||
|
||||
const supportStore = getContext("adminSupportStore");
|
||||
let reply = "";
|
||||
let messagesScrollEl;
|
||||
let lastMessageScrollKey = "";
|
||||
|
||||
$: ({
|
||||
tickets,
|
||||
stats,
|
||||
loading,
|
||||
filters,
|
||||
openedTicketId,
|
||||
openedTicket,
|
||||
messages,
|
||||
userSnapshot,
|
||||
sending,
|
||||
composerInternalNote,
|
||||
} = $supportStore);
|
||||
$: statusTabs = [
|
||||
{
|
||||
value: "active",
|
||||
label: at("support_filter_active", {}, "Активные"),
|
||||
count: stats?.active || 0,
|
||||
},
|
||||
{
|
||||
value: "closed",
|
||||
label: at("support_filter_closed", {}, "Закрытые"),
|
||||
count: stats?.closed || 0,
|
||||
},
|
||||
];
|
||||
$: priorityFilterOptions = [
|
||||
{ value: "all", label: at("support_filter_all_priorities", {}, "Любой приоритет") },
|
||||
{ value: "low", label: at("support_priority_low", {}, "Низкий") },
|
||||
{ value: "normal", label: at("support_priority_normal", {}, "Обычный") },
|
||||
{ value: "high", label: at("support_priority_high", {}, "Высокий") },
|
||||
{ value: "urgent", label: at("support_priority_urgent", {}, "Срочный") },
|
||||
];
|
||||
$: categoryFilterOptions = [
|
||||
{ value: "all", label: at("support_filter_all_categories", {}, "Все категории") },
|
||||
{ value: "billing", label: at("support_category_billing", {}, "Оплата") },
|
||||
{ value: "technical", label: at("support_category_technical", {}, "Техническое") },
|
||||
{ value: "account", label: at("support_category_account", {}, "Аккаунт") },
|
||||
{ value: "other", label: at("support_category_other", {}, "Другое") },
|
||||
];
|
||||
$: sortOptions = [
|
||||
{ value: "importance_desc", label: at("support_sort_importance_desc", {}, "Важные сверху") },
|
||||
{ value: "updated_desc", label: at("sort_updated_desc", {}, "Сначала новые") },
|
||||
{ value: "updated_asc", label: at("sort_updated_asc", {}, "Сначала старые") },
|
||||
{ value: "created_desc", label: at("sort_created_desc", {}, "Созданы недавно") },
|
||||
{ value: "created_asc", label: at("sort_created_asc", {}, "Созданы давно") },
|
||||
];
|
||||
$: ticketReady = Boolean(openedTicket && openedTicket.ticket_id === openedTicketId);
|
||||
$: modalTitle = ticketReady
|
||||
? openedTicket.subject
|
||||
: openedTicketId
|
||||
? at("support_ticket_number", { id: openedTicketId }, `Тикет #${openedTicketId}`)
|
||||
: at("support_ticket_dialog", {}, "Диалог поддержки");
|
||||
$: modalDescription = ticketReady
|
||||
? at("support_ticket_number", { id: openedTicketId }, `Тикет #${openedTicketId}`)
|
||||
: at("loading", {}, "Загрузка");
|
||||
$: openedTicketUser = openedTicket?.user || {};
|
||||
$: openedTicketUserAvatarUrl = resolvedAvatarUrl(openedTicketUser);
|
||||
$: openedTicketUserInitials = userInitials(openedTicketUser);
|
||||
$: if (!openedTicketId) {
|
||||
reply = "";
|
||||
lastMessageScrollKey = "";
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
supportStore.loadList();
|
||||
supportStore.loadStats();
|
||||
supportStore.startStatsPolling();
|
||||
if (initialTicketId) supportStore.openTicket(initialTicketId, { skipPush: true });
|
||||
});
|
||||
|
||||
async function send(body) {
|
||||
await supportStore.sendReply(body);
|
||||
reply = "";
|
||||
}
|
||||
|
||||
function scrollMessagesToBottom() {
|
||||
if (!messagesScrollEl) return;
|
||||
const scroll = () => {
|
||||
messagesScrollEl.scrollTop = messagesScrollEl.scrollHeight;
|
||||
};
|
||||
scroll();
|
||||
requestAnimationFrame(scroll);
|
||||
window.setTimeout(scroll, 80);
|
||||
window.setTimeout(scroll, 180);
|
||||
}
|
||||
|
||||
function closeTicketModal() {
|
||||
reply = "";
|
||||
supportStore.closeTicketView();
|
||||
}
|
||||
|
||||
function setFilter(key, value) {
|
||||
supportStore.setFilter(key, value === "all" ? "" : value);
|
||||
}
|
||||
|
||||
function setFilterAndLoad(key, value) {
|
||||
setFilter(key, value);
|
||||
supportStore.loadList();
|
||||
}
|
||||
|
||||
function messageT(key, params = {}, fallback = "") {
|
||||
if (key.startsWith("wa_support_")) {
|
||||
return at(key.replace("wa_support_", "support_"), params, fallback || key);
|
||||
}
|
||||
return at(key, params, fallback || key);
|
||||
}
|
||||
|
||||
function userInitials(user) {
|
||||
const source =
|
||||
[user?.first_name, user?.last_name].filter(Boolean).join(" ").trim() ||
|
||||
user?.username ||
|
||||
user?.email ||
|
||||
String(user?.user_id || "");
|
||||
const clean = String(source).replace(/^@/, "").trim();
|
||||
const parts = clean.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
return (clean.slice(0, 2) || "U").toUpperCase();
|
||||
}
|
||||
|
||||
function ticketUserDisplayName() {
|
||||
const user = openedTicketUser || {};
|
||||
const fullName = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
|
||||
return (
|
||||
snapshotName(userSnapshot) ||
|
||||
fullName ||
|
||||
user.username ||
|
||||
user.email ||
|
||||
String(user.user_id || "")
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotName(snapshot) {
|
||||
return String(snapshot?.name || "").trim();
|
||||
}
|
||||
|
||||
function messageAuthorName(message) {
|
||||
if (message?.author_name) return message.author_name;
|
||||
if (message?.author_role === "user") return ticketUserDisplayName();
|
||||
if (message?.author_role === "admin" && message?.author_user_id) {
|
||||
return `${at("support_role_admin", {}, "Админ")} #${message.author_user_id}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
afterUpdate(async () => {
|
||||
const lastMessage = messages.at(-1);
|
||||
const nextKey = `${openedTicketId || ""}:${ticketReady}:${messages.length}:${
|
||||
lastMessage?.message_id || lastMessage?.created_at || ""
|
||||
}`;
|
||||
if (!openedTicketId || !ticketReady || !messagesScrollEl || nextKey === lastMessageScrollKey) {
|
||||
return;
|
||||
}
|
||||
lastMessageScrollKey = nextKey;
|
||||
await tick();
|
||||
scrollMessagesToBottom();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="support-admin-layout">
|
||||
<div class="support-admin-summary" aria-label={at("support_summary", {}, "Сводка поддержки")}>
|
||||
<span>
|
||||
<strong>{stats?.open || 0}</strong>
|
||||
<small>{at("support_status_open", {}, "Открыт")}</small>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{stats?.awaiting_admin || 0}</strong>
|
||||
<small>{at("support_status_awaiting_admin", {}, "Ждет админа")}</small>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{stats?.total_unread_admin || 0}</strong>
|
||||
<small>{at("support_unread", {}, "Непрочитано")}</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="support-admin-list-panel">
|
||||
<div class="support-admin-ticket-tabs" aria-label={at("support_status", {}, "Статус")}>
|
||||
{#each statusTabs as tab (tab.value)}
|
||||
<button
|
||||
type="button"
|
||||
class:active={filters.status === tab.value}
|
||||
on:click={() => supportStore.setStatusView(tab.value)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
<b>{tab.count}</b>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="support-admin-toolbar admin-toolbar-card">
|
||||
<label class="support-admin-search">
|
||||
<Search size={16} />
|
||||
<input
|
||||
class="input"
|
||||
type="search"
|
||||
placeholder={at("support_search", {}, "Поиск")}
|
||||
value={filters.search}
|
||||
on:input={(e) => supportStore.setFilter("search", e.target.value)}
|
||||
on:keydown={(e) => e.key === "Enter" && supportStore.loadList()}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="support-admin-filter-row">
|
||||
<AdminSelect
|
||||
value={filters.priority || "all"}
|
||||
items={priorityFilterOptions}
|
||||
ariaLabel={at("support_priority", {}, "Приоритет")}
|
||||
onValueChange={(value) => setFilterAndLoad("priority", value)}
|
||||
/>
|
||||
<AdminSelect
|
||||
value={filters.category || "all"}
|
||||
items={categoryFilterOptions}
|
||||
ariaLabel={at("support_category", {}, "Категория")}
|
||||
onValueChange={(value) => setFilterAndLoad("category", value)}
|
||||
/>
|
||||
<AdminSelect
|
||||
value={filters.sort || "importance_desc"}
|
||||
items={sortOptions}
|
||||
ariaLabel={at("sort", {}, "Сортировка")}
|
||||
onValueChange={(value) => setFilterAndLoad("sort", value)}
|
||||
/>
|
||||
<AdminButton variant="primary" onclick={() => supportStore.loadList()}>
|
||||
{at("apply", {}, "Применить")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="support-ticket-list-skeleton" aria-label={at("loading", {}, "Загрузка")}>
|
||||
{#each Array(6) as _, index (index)}
|
||||
<article class="support-ticket-row-skeleton">
|
||||
<Skeleton variant="dot" width="38px" height="38px" />
|
||||
<span class="support-ticket-row-skeleton-main">
|
||||
<Skeleton variant="title" width="min(380px, 74%)" />
|
||||
<Skeleton variant="short" width="min(280px, 58%)" />
|
||||
</span>
|
||||
<span class="support-ticket-row-skeleton-side">
|
||||
<Skeleton variant="badge" width="92px" />
|
||||
<Skeleton variant="tiny" width="64px" />
|
||||
</span>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !tickets.length}
|
||||
<div class="admin-empty-state">{at("support_empty", {}, "Тикетов пока нет")}</div>
|
||||
{:else}
|
||||
<div class="support-inbox-list">
|
||||
{#each tickets as ticket}
|
||||
<SupportInboxRow
|
||||
{ticket}
|
||||
active={openedTicketId === ticket.ticket_id}
|
||||
{at}
|
||||
onOpen={(item) => supportStore.openTicket(item.ticket_id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(openedTicketId)}
|
||||
title={modalTitle}
|
||||
description={modalDescription}
|
||||
closeLabel={at("close", {}, "Закрыть")}
|
||||
onclose={closeTicketModal}
|
||||
class="admin-dialog support-ticket-dialog"
|
||||
>
|
||||
{#if !ticketReady}
|
||||
<div class="support-ticket-dialog-skeleton">
|
||||
<Skeleton variant="title" width="70%" />
|
||||
<Skeleton variant="short" width="44%" />
|
||||
<Skeleton variant="block" height="94px" />
|
||||
<Skeleton variant="block" height="220px" />
|
||||
<Skeleton variant="block" height="132px" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="support-ticket-dialog-body">
|
||||
<SupportTicketHeader
|
||||
ticket={openedTicket}
|
||||
{at}
|
||||
onPatch={(updates) => supportStore.patchTicket(updates)}
|
||||
onClose={() => supportStore.closeTicket()}
|
||||
/>
|
||||
<SupportUserContextPanel ticket={openedTicket} snapshot={userSnapshot} {at} />
|
||||
<ScrollArea
|
||||
bind:element={messagesScrollEl}
|
||||
maxHeight="none"
|
||||
class="support-admin-message-scroll scroll-area--mono"
|
||||
>
|
||||
<div class="support-admin-messages">
|
||||
{#if messages.length}
|
||||
{#each messages as message}
|
||||
<TicketMessageBubble
|
||||
role={message.author_role}
|
||||
body={message.body}
|
||||
createdAt={message.created_at}
|
||||
isInternalNote={message.is_internal_note}
|
||||
perspective="admin"
|
||||
supportBrand={brand}
|
||||
userAvatarUrl={openedTicketUserAvatarUrl}
|
||||
userInitials={openedTicketUserInitials}
|
||||
authorName={messageAuthorName(message)}
|
||||
t={messageT}
|
||||
/>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="admin-empty-state">
|
||||
{at("support_no_messages", {}, "Сообщений пока нет")}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<SupportComposer
|
||||
bind:value={reply}
|
||||
internal={composerInternalNote}
|
||||
{sending}
|
||||
{at}
|
||||
onToggleInternal={supportStore.toggleInternalNote}
|
||||
onSend={send}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog>
|
||||
@@ -0,0 +1,216 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createAdminSupportStore({ api, onToast, at }) {
|
||||
const state = writable({
|
||||
tickets: [],
|
||||
stats: { active: 0, closed: 0, open: 0, awaiting_admin: 0, total_unread_admin: 0 },
|
||||
filters: {
|
||||
status: "active",
|
||||
priority: "",
|
||||
category: "",
|
||||
search: "",
|
||||
sort: "importance_desc",
|
||||
},
|
||||
loading: false,
|
||||
openedTicketId: null,
|
||||
openedTicket: null,
|
||||
messages: [],
|
||||
userSnapshot: null,
|
||||
detailLoading: false,
|
||||
sending: false,
|
||||
composerInternalNote: false,
|
||||
});
|
||||
|
||||
let pollTimer = null;
|
||||
let active = "stats";
|
||||
|
||||
function setActive(section) {
|
||||
active = section;
|
||||
}
|
||||
|
||||
function pushTicketPath(ticketId) {
|
||||
if (typeof window === "undefined" || window.location.protocol === "file:") return;
|
||||
if (active !== "support") return;
|
||||
const target = ticketId ? `/admin/support/${ticketId}` : "/admin/support";
|
||||
if (window.location.pathname !== target) {
|
||||
window.history.pushState(
|
||||
null,
|
||||
"",
|
||||
`${target}${window.location.search}${window.location.hash}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const res = await api("/admin/support/stats");
|
||||
if (res?.ok) state.update((s) => ({ ...s, stats: res.stats || s.stats }));
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
state.update((s) => ({ ...s, loading: true }));
|
||||
let filters;
|
||||
state.update((s) => {
|
||||
filters = s.filters;
|
||||
return s;
|
||||
});
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: "50", offset: "0" });
|
||||
for (const [key, value] of Object.entries(filters || {})) {
|
||||
if (value) params.set(key, value);
|
||||
}
|
||||
const res = await api(`/admin/support/tickets?${params.toString()}`);
|
||||
if (res?.ok) state.update((s) => ({ ...s, tickets: res.tickets || [] }));
|
||||
else if (res?.error) onToast(res.message || res.error);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, loading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function openTicket(ticketId, opts = {}) {
|
||||
const id = Number(ticketId);
|
||||
if (!id) return;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedTicketId: id,
|
||||
openedTicket: s.openedTicket?.ticket_id === id ? s.openedTicket : null,
|
||||
messages: s.openedTicket?.ticket_id === id ? s.messages : [],
|
||||
userSnapshot: s.openedTicket?.ticket_id === id ? s.userSnapshot : null,
|
||||
detailLoading: true,
|
||||
}));
|
||||
if (!opts.skipPush) pushTicketPath(id);
|
||||
try {
|
||||
const res = await api(`/admin/support/tickets/${id}`);
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedTicket: res.ticket,
|
||||
messages: res.messages || [],
|
||||
userSnapshot: res.user_snapshot || null,
|
||||
}));
|
||||
await api(`/admin/support/tickets/${id}/read`, { method: "POST", body: "{}" });
|
||||
await loadStats();
|
||||
} else onToast(res?.message || res?.error || "not_found");
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, detailLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function closeTicketView(opts = {}) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedTicketId: null,
|
||||
openedTicket: null,
|
||||
messages: [],
|
||||
userSnapshot: null,
|
||||
}));
|
||||
if (!opts.skipPush) pushTicketPath(null);
|
||||
}
|
||||
|
||||
async function sendReply(body) {
|
||||
let current;
|
||||
let internal;
|
||||
state.update((s) => {
|
||||
current = s.openedTicketId;
|
||||
internal = s.composerInternalNote;
|
||||
return { ...s, sending: true };
|
||||
});
|
||||
if (!current) return;
|
||||
try {
|
||||
const res = await api(`/admin/support/tickets/${current}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body, is_internal_note: internal }),
|
||||
});
|
||||
if (!res?.ok) throw res;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedTicket: res.ticket
|
||||
? { ...s.openedTicket, ...res.ticket, user: res.ticket.user || s.openedTicket?.user }
|
||||
: s.openedTicket,
|
||||
messages: [...s.messages, res.message],
|
||||
}));
|
||||
await loadList();
|
||||
await loadStats();
|
||||
} catch (error) {
|
||||
onToast(error?.message || at("support_send_failed", {}, "Send failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, sending: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function patchTicket(updates) {
|
||||
let current;
|
||||
state.update((s) => {
|
||||
current = s.openedTicketId;
|
||||
return s;
|
||||
});
|
||||
if (!current) return;
|
||||
const res = await api(`/admin/support/tickets/${current}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedTicket: res.ticket
|
||||
? { ...s.openedTicket, ...res.ticket, user: res.ticket.user || s.openedTicket?.user }
|
||||
: s.openedTicket,
|
||||
}));
|
||||
await loadList();
|
||||
await loadStats();
|
||||
} else onToast(res?.message || res?.error || "update_failed");
|
||||
}
|
||||
|
||||
function closeTicket() {
|
||||
patchTicket({ status: "closed" });
|
||||
}
|
||||
|
||||
function toggleInternalNote() {
|
||||
state.update((s) => ({ ...s, composerInternalNote: !s.composerInternalNote }));
|
||||
}
|
||||
|
||||
function setFilter(key, value) {
|
||||
state.update((s) => ({ ...s, filters: { ...s.filters, [key]: value } }));
|
||||
}
|
||||
|
||||
function setStatusView(status) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
filters: {
|
||||
...s.filters,
|
||||
status: status === "closed" ? "closed" : "active",
|
||||
},
|
||||
}));
|
||||
loadList();
|
||||
}
|
||||
|
||||
function startStatsPolling() {
|
||||
if (pollTimer || typeof window === "undefined") return;
|
||||
loadStats();
|
||||
pollTimer = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") loadStats();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function stopStatsPolling() {
|
||||
if (pollTimer) window.clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
update: state.update,
|
||||
setActive,
|
||||
loadStats,
|
||||
loadList,
|
||||
openTicket,
|
||||
closeTicketView,
|
||||
sendReply,
|
||||
patchTicket,
|
||||
closeTicket,
|
||||
toggleInternalNote,
|
||||
setFilter,
|
||||
setStatusView,
|
||||
startStatsPolling,
|
||||
stopStatsPolling,
|
||||
};
|
||||
}
|
||||
@@ -69,13 +69,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
|
||||
body,
|
||||
});
|
||||
if (data?.ok) {
|
||||
flash(
|
||||
at(
|
||||
"appearance_logo_uploaded_pending",
|
||||
{},
|
||||
"Логотип загружен и применен."
|
||||
)
|
||||
);
|
||||
flash(at("appearance_logo_uploaded_pending", {}, "Логотип загружен и применен."));
|
||||
return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" };
|
||||
}
|
||||
flash(
|
||||
@@ -99,13 +93,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
|
||||
body: JSON.stringify({ url: sourceUrl }),
|
||||
});
|
||||
if (data?.ok) {
|
||||
flash(
|
||||
at(
|
||||
"appearance_logo_uploaded_pending",
|
||||
{},
|
||||
"Логотип загружен и применен."
|
||||
)
|
||||
);
|
||||
flash(at("appearance_logo_uploaded_pending", {}, "Логотип загружен и применен."));
|
||||
return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" };
|
||||
}
|
||||
flash(
|
||||
@@ -130,13 +118,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
|
||||
body,
|
||||
});
|
||||
if (data?.ok) {
|
||||
flash(
|
||||
at(
|
||||
"appearance_favicon_uploaded_pending",
|
||||
{},
|
||||
"Favicon загружена и применена."
|
||||
)
|
||||
);
|
||||
flash(at("appearance_favicon_uploaded_pending", {}, "Favicon загружена и применена."));
|
||||
return { faviconUrl: data.favicon_url || "", variants: data.variants || {} };
|
||||
}
|
||||
flash(
|
||||
@@ -160,13 +142,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
|
||||
body: JSON.stringify({ url: sourceUrl }),
|
||||
});
|
||||
if (data?.ok) {
|
||||
flash(
|
||||
at(
|
||||
"appearance_favicon_uploaded_pending",
|
||||
{},
|
||||
"Favicon загружена и применена."
|
||||
)
|
||||
);
|
||||
flash(at("appearance_favicon_uploaded_pending", {}, "Favicon загружена и применена."));
|
||||
return { faviconUrl: data.favicon_url || "", variants: data.variants || {} };
|
||||
}
|
||||
flash(
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
const accent = readCssColor("--accent", "#00fe7a");
|
||||
const lineStroke = readCssColor(
|
||||
"--admin-chart-stroke",
|
||||
readCssColor("--admin-text", "#e8f0ec"),
|
||||
readCssColor("--admin-text", "#e8f0ec")
|
||||
);
|
||||
const lineFill = readCssColor("--admin-chart-fill", "rgba(120, 140, 132, 0.14)");
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script>
|
||||
import { Lock, Send } from "$components/ui/icons.js";
|
||||
import { Spinner, Textarea } from "$components/ui/index.js";
|
||||
import { Switch } from "$components/ui/primitives.js";
|
||||
import { AdminButton } from "$components/patterns/admin/index.js";
|
||||
|
||||
export let value = "";
|
||||
export let internal = false;
|
||||
export let sending = false;
|
||||
export let at = (key) => key;
|
||||
export let onToggleInternal = () => {};
|
||||
export let onSend = () => {};
|
||||
|
||||
function submit() {
|
||||
if (sending || !value.trim()) return;
|
||||
onSend(value.trim());
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="support-admin-composer">
|
||||
<Textarea
|
||||
bind:value
|
||||
rows={4}
|
||||
placeholder={at("support_reply_placeholder", {}, "Ответ")}
|
||||
ariaLabel={at("support_reply_placeholder", {}, "Ответ")}
|
||||
class="support-admin-composer-textarea"
|
||||
/>
|
||||
|
||||
<div class="support-admin-composer-row">
|
||||
<div class="support-admin-note-toggle">
|
||||
<Switch.Root
|
||||
id="support-internal-note"
|
||||
checked={internal}
|
||||
onCheckedChange={onToggleInternal}
|
||||
class="admin-switch-root"
|
||||
>
|
||||
<Switch.Thumb class="admin-switch-thumb" />
|
||||
</Switch.Root>
|
||||
<label for="support-internal-note">
|
||||
<Lock size={14} />
|
||||
<span>{at("support_internal_note", {}, "Внутренняя заметка")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<AdminButton variant="primary" disabled={sending || !value.trim()} onclick={submit}>
|
||||
{#if sending}<Spinner size="sm" />{:else}<Send size={14} />{/if}
|
||||
{at("send", {}, "Отправить")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script>
|
||||
import { AdminBadge } from "$components/patterns/admin/index.js";
|
||||
import { MessageSquare } from "$components/ui/icons.js";
|
||||
|
||||
export let ticket;
|
||||
export let active = false;
|
||||
export let at = (key) => key;
|
||||
export let onOpen = () => {};
|
||||
|
||||
$: user = ticket?.user || {};
|
||||
$: timeLabel = formatTime(ticket?.last_message_at || ticket?.updated_at || ticket?.created_at);
|
||||
$: userLabel = user.username ? `@${user.username}` : user.email || user.user_id || "-";
|
||||
$: avatarUrl = user?.avatar_url || user?.photo_url || "";
|
||||
$: avatarInitials = computeInitials(user);
|
||||
$: categoryLabel = at(`support_category_${ticket?.category}`, {}, ticket?.category || "-");
|
||||
$: statusVariant =
|
||||
ticket?.status === "closed" || ticket?.status === "resolved" ? "muted" : "success";
|
||||
$: priorityVariant =
|
||||
ticket?.priority === "urgent" ? "danger" : ticket?.priority === "high" ? "warning" : "muted";
|
||||
|
||||
function computeInitials(u) {
|
||||
const source =
|
||||
[u?.first_name, u?.last_name].filter(Boolean).join(" ").trim() ||
|
||||
u?.username ||
|
||||
u?.email ||
|
||||
String(u?.user_id || "");
|
||||
const clean = String(source).replace(/^@/, "").trim();
|
||||
const parts = clean.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
return (clean.slice(0, 2) || "U").toUpperCase();
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class:active
|
||||
class="support-inbox-row"
|
||||
type="button"
|
||||
data-status={ticket?.status}
|
||||
data-priority={ticket?.priority}
|
||||
on:click={() => onOpen(ticket)}
|
||||
>
|
||||
<span class="support-inbox-row-avatar" aria-hidden="true">
|
||||
{#if avatarUrl}
|
||||
<img src={avatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||
{:else}
|
||||
{avatarInitials}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="support-inbox-row-main">
|
||||
<span class="support-inbox-row-title">
|
||||
<MessageSquare size={15} />
|
||||
<strong>{ticket.subject}</strong>
|
||||
</span>
|
||||
<small>#{ticket.ticket_id} / {userLabel} / {categoryLabel}</small>
|
||||
</span>
|
||||
|
||||
<span class="support-row-badges">
|
||||
<AdminBadge variant={statusVariant}
|
||||
>{at(`support_status_${ticket.status}`, {}, ticket.status)}</AdminBadge
|
||||
>
|
||||
<AdminBadge variant={priorityVariant}>
|
||||
{at(`support_priority_${ticket.priority}`, {}, ticket.priority)}
|
||||
</AdminBadge>
|
||||
{#if ticket.unread_admin_count}
|
||||
<b>
|
||||
<span class="numeric-badge-value">{ticket.unread_admin_count}</span>
|
||||
</b>
|
||||
{/if}
|
||||
{#if timeLabel}<small>{timeLabel}</small>{/if}
|
||||
</span>
|
||||
</button>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script>
|
||||
import { AdminBadge, AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
|
||||
import { CheckCheck } from "$components/ui/icons.js";
|
||||
|
||||
export let ticket;
|
||||
export let at = (key) => key;
|
||||
export let onPatch = () => {};
|
||||
export let onClose = () => {};
|
||||
|
||||
$: statusOptions = ["open", "awaiting_user", "awaiting_admin", "resolved", "closed"].map(
|
||||
(item) => ({
|
||||
value: item,
|
||||
label: at(`support_status_${item}`, {}, item),
|
||||
})
|
||||
);
|
||||
$: priorityOptions = ["low", "normal", "high", "urgent"].map((item) => ({
|
||||
value: item,
|
||||
label: at(`support_priority_${item}`, {}, item),
|
||||
}));
|
||||
$: categoryOptions = ["billing", "technical", "account", "other"].map((item) => ({
|
||||
value: item,
|
||||
label: at(`support_category_${item}`, {}, item),
|
||||
}));
|
||||
$: statusVariant =
|
||||
ticket?.status === "closed" || ticket?.status === "resolved" ? "muted" : "success";
|
||||
$: priorityVariant =
|
||||
ticket?.priority === "urgent" ? "danger" : ticket?.priority === "high" ? "warning" : "muted";
|
||||
|
||||
function patch(key, value) {
|
||||
if (!ticket || ticket[key] === value) return;
|
||||
onPatch({ [key]: value });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if ticket}
|
||||
<div class="support-ticket-header">
|
||||
<div class="support-ticket-statusbar">
|
||||
<AdminBadge variant={statusVariant}>
|
||||
{at(`support_status_${ticket.status}`, {}, ticket.status)}
|
||||
</AdminBadge>
|
||||
<AdminBadge variant={priorityVariant}>
|
||||
{at(`support_priority_${ticket.priority}`, {}, ticket.priority)}
|
||||
</AdminBadge>
|
||||
</div>
|
||||
|
||||
<div class="support-ticket-actions">
|
||||
<AdminButton
|
||||
class="support-ticket-close"
|
||||
variant="dangerSoft"
|
||||
onclick={onClose}
|
||||
disabled={ticket.status === "closed"}
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
{at("support_close_ticket", {}, "Закрыть тикет")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
|
||||
<div class="support-ticket-controls">
|
||||
<AdminSelect
|
||||
value={ticket.status}
|
||||
items={statusOptions}
|
||||
ariaLabel={at("support_status", {}, "Статус")}
|
||||
onValueChange={(value) => patch("status", value)}
|
||||
/>
|
||||
<AdminSelect
|
||||
value={ticket.priority}
|
||||
items={priorityOptions}
|
||||
ariaLabel={at("support_priority", {}, "Приоритет")}
|
||||
onValueChange={(value) => patch("priority", value)}
|
||||
/>
|
||||
<AdminSelect
|
||||
value={ticket.category}
|
||||
items={categoryOptions}
|
||||
ariaLabel={at("support_category", {}, "Категория")}
|
||||
onValueChange={(value) => patch("category", value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,75 @@
|
||||
<script>
|
||||
import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js";
|
||||
import { User } from "$components/ui/icons.js";
|
||||
|
||||
export let ticket;
|
||||
export let snapshot = {};
|
||||
export let at = (key) => key;
|
||||
|
||||
$: user = ticket?.user || {};
|
||||
$: displayName = snapshot?.name || user.username || user.email || user.user_id || "-";
|
||||
$: avatarUrl = user?.avatar_url || user?.photo_url || "";
|
||||
$: avatarInitials = computeInitials(user, displayName);
|
||||
$: canOpenUser = user.user_id !== undefined && user.user_id !== null && user.user_id !== "";
|
||||
$: identityMeta =
|
||||
[user.email, canOpenUser ? `ID ${user.user_id}` : ""].filter(Boolean).join(" / ") || "-";
|
||||
$: contextItems = [
|
||||
{ label: at("support_tariff", {}, "Тариф"), value: snapshot?.tariff || "-" },
|
||||
{ label: at("support_status", {}, "Статус"), value: snapshot?.panel_status || "-" },
|
||||
{ label: at("support_remaining", {}, "Осталось"), value: snapshot?.remaining || "-" },
|
||||
];
|
||||
|
||||
function computeInitials(u, fallback) {
|
||||
const source =
|
||||
[u?.first_name, u?.last_name].filter(Boolean).join(" ").trim() ||
|
||||
u?.username ||
|
||||
u?.email ||
|
||||
String(fallback || "");
|
||||
const clean = String(source).replace(/^@/, "").trim();
|
||||
const parts = clean.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
return (clean.slice(0, 2) || "U").toUpperCase();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="support-user-context" aria-label={at("support_user_context", {}, "User")}>
|
||||
<div class="support-user-context-head">
|
||||
<span class="support-user-context-avatar" aria-hidden="true">
|
||||
{#if avatarUrl}
|
||||
<img src={avatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||
{:else}
|
||||
{avatarInitials}
|
||||
{/if}
|
||||
</span>
|
||||
<div class="support-user-context-identity">
|
||||
<strong>{displayName}</strong>
|
||||
<small>{identityMeta}</small>
|
||||
{#if user.is_banned}
|
||||
<AdminBadge variant="danger">{at("status_banned", {}, "Бан")}</AdminBadge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="support-user-context-metrics">
|
||||
{#each contextItems as item (item.label)}
|
||||
<span>
|
||||
<small>{item.label}</small>
|
||||
<strong>{item.value}</strong>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="support-user-context-actions">
|
||||
<AdminButton
|
||||
class="support-user-card-btn"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!canOpenUser}
|
||||
onclick={() => (window.location.href = `/admin/users/${user.user_id}`)}
|
||||
aria-label={at("support_open_user", {}, "Карточка")}
|
||||
title={at("support_open_user", {}, "Карточка")}
|
||||
>
|
||||
<User size={14} />
|
||||
</AdminButton>
|
||||
</div>
|
||||
</section>
|
||||
@@ -12,3 +12,7 @@ export { default as AdminSectionHeader } from "./AdminSectionHeader.svelte";
|
||||
export { default as AdminTable } from "./AdminTable.svelte";
|
||||
export { default as AdminTableSkeleton } from "./AdminTableSkeleton.svelte";
|
||||
export { default as AdminTrafficCard } from "./AdminTrafficCard.svelte";
|
||||
export { default as SupportComposer } from "./SupportComposer.svelte";
|
||||
export { default as SupportInboxRow } from "./SupportInboxRow.svelte";
|
||||
export { default as SupportTicketHeader } from "./SupportTicketHeader.svelte";
|
||||
export { default as SupportUserContextPanel } from "./SupportUserContextPanel.svelte";
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script>
|
||||
import { AttentionDot, Badge } from "$components/ui/index.js";
|
||||
import { MessageSquare } from "$components/ui/icons.js";
|
||||
|
||||
export let ticket;
|
||||
export let t = (key) => key;
|
||||
export let onOpen = () => {};
|
||||
|
||||
$: unread = Number(ticket?.unread_user_count || 0);
|
||||
$: timeLabel = formatTime(ticket?.last_message_at || ticket?.updated_at || ticket?.created_at);
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="ticket-card"
|
||||
type="button"
|
||||
data-status={ticket?.status}
|
||||
data-priority={ticket?.priority}
|
||||
on:click={() => onOpen(ticket)}
|
||||
>
|
||||
<span class="ticket-card-main">
|
||||
<span class="ticket-card-title">
|
||||
<MessageSquare size={16} />
|
||||
<strong>{ticket.subject}</strong>
|
||||
</span>
|
||||
<span class="ticket-card-meta">
|
||||
<span>{t("wa_support_ticket_number", { id: ticket.ticket_id })}</span>
|
||||
{#if timeLabel}<span>{timeLabel}</span>{/if}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="ticket-card-side">
|
||||
<span class="ticket-card-badges">
|
||||
<Badge variant="outline" class={`ticket-status-badge ticket-status-badge--${ticket.status}`}>
|
||||
{t(`wa_support_status_${ticket.status}`)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="muted"
|
||||
class={`ticket-priority-badge ticket-priority-badge--${ticket.priority}`}
|
||||
>
|
||||
{t(`wa_support_priority_${ticket.priority}`)}
|
||||
</Badge>
|
||||
</span>
|
||||
{#if unread}
|
||||
<AttentionDot position="inline" class="ticket-card-unread-dot" />
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script>
|
||||
import { Send } from "$components/ui/icons.js";
|
||||
import { Button, Spinner, Textarea } from "$components/ui/index.js";
|
||||
|
||||
export let value = "";
|
||||
export let maxLength = 4000;
|
||||
export let disabled = false;
|
||||
export let sending = false;
|
||||
export let placeholder = "";
|
||||
export let sendLabel = "";
|
||||
export let onSend = () => {};
|
||||
|
||||
function submit() {
|
||||
if (disabled || sending || !value.trim()) return;
|
||||
onSend(value.trim());
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ticket-composer">
|
||||
<Textarea
|
||||
bind:value
|
||||
rows={3}
|
||||
maxlength={maxLength}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
ariaLabel={placeholder}
|
||||
class="ticket-composer-textarea"
|
||||
/>
|
||||
<div class="ticket-composer-row">
|
||||
<small>{value.length}/{maxLength}</small>
|
||||
<Button
|
||||
type="button"
|
||||
class="ticket-composer-send"
|
||||
disabled={disabled || sending || !value.trim()}
|
||||
onclick={submit}
|
||||
>
|
||||
{#if sending}<Spinner size="sm" />{:else}<Send size={16} />{/if}
|
||||
<span>{sendLabel}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script>
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
import { LifeBuoy, Lock, MessageSquare, UserRound } from "$components/ui/icons.js";
|
||||
|
||||
export let role = "user";
|
||||
export let body = "";
|
||||
export let createdAt = "";
|
||||
export let isInternalNote = false;
|
||||
export let perspective = "user";
|
||||
export let userAvatarUrl = "";
|
||||
export let userInitials = "";
|
||||
export let authorName = "";
|
||||
export let supportBrand = {};
|
||||
export let t = (key, _params = {}, fallback = "") => fallback || key;
|
||||
|
||||
$: messageRole = role || "system";
|
||||
$: serviceMessage = isInternalNote || messageRole === "system";
|
||||
$: outgoing =
|
||||
(perspective === "admin" && (messageRole === "admin" || serviceMessage)) ||
|
||||
(!serviceMessage && perspective !== "admin" && messageRole === "user");
|
||||
$: roleLabel = isInternalNote
|
||||
? [authorName, t("wa_support_internal_note", {}, "Внутренняя заметка")]
|
||||
.filter(Boolean)
|
||||
.join(" / ")
|
||||
: authorName || t(`wa_support_role_${messageRole}`, {}, messageRole);
|
||||
$: timeLabel = formatTime(createdAt);
|
||||
$: showSupportAvatar = !isInternalNote && messageRole === "admin";
|
||||
$: showUserAvatar = !isInternalNote && messageRole === "user";
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<article
|
||||
class={`ticket-message-row ticket-message-row--${messageRole}`.trim()}
|
||||
class:ticket-message-row--outgoing={outgoing}
|
||||
class:ticket-message-row--incoming={!outgoing}
|
||||
class:ticket-message-row--internal={isInternalNote}
|
||||
>
|
||||
<span class="ticket-message-avatar" aria-hidden="true">
|
||||
{#if isInternalNote}
|
||||
<Lock size={15} />
|
||||
{:else if showSupportAvatar}
|
||||
<BrandMark brand={supportBrand} size="sm" fallbackEmoji={true} />
|
||||
{:else if showUserAvatar && userAvatarUrl}
|
||||
<img src={userAvatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||
{:else if showUserAvatar && userInitials}
|
||||
<strong>{userInitials}</strong>
|
||||
{:else if messageRole === "admin"}
|
||||
<LifeBuoy size={15} />
|
||||
{:else if messageRole === "user"}
|
||||
<UserRound size={15} />
|
||||
{:else}
|
||||
<MessageSquare size={15} />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<div class="ticket-message-content">
|
||||
<div class="ticket-message-meta">
|
||||
<span class="ticket-message-author">{roleLabel}</span>
|
||||
{#if timeLabel}
|
||||
<time datetime={createdAt}>{timeLabel}</time>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ticket-message-bubble">
|
||||
<p>{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -4,3 +4,6 @@ export { default as LinearProgress } from "./LinearProgress.svelte";
|
||||
export { default as LanguageSelect } from "./LanguageSelect.svelte";
|
||||
export { default as PaymentMethodGrid } from "./PaymentMethodGrid.svelte";
|
||||
export { default as StatusMessage } from "./StatusMessage.svelte";
|
||||
export { default as TicketCard } from "./TicketCard.svelte";
|
||||
export { default as TicketComposer } from "./TicketComposer.svelte";
|
||||
export { default as TicketMessageBubble } from "./TicketMessageBubble.svelte";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let position = "absolute";
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<span
|
||||
data-slot="attention-dot"
|
||||
aria-hidden="true"
|
||||
class={cn("attention-dot", position === "inline" && "attention-dot-inline", className)}
|
||||
{...$$restProps}
|
||||
></span>
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
Bitcoin,
|
||||
CalendarDays,
|
||||
Check,
|
||||
CheckCheck,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
@@ -28,12 +29,15 @@ export {
|
||||
Info,
|
||||
Key,
|
||||
LayoutDashboard,
|
||||
LifeBuoy,
|
||||
Lock,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
Map,
|
||||
Megaphone,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
MessageSquarePlus,
|
||||
MousePointerClick,
|
||||
Paintbrush,
|
||||
Plus,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as AttentionDot } from "./attention-dot.svelte";
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { default as Button } from "./button.svelte";
|
||||
export { default as Dialog } from "./dialog.svelte";
|
||||
@@ -5,6 +6,8 @@ export { default as Input } from "./input.svelte";
|
||||
export { default as LegacyCard } from "./card.svelte";
|
||||
export { default as Skeleton } from "./skeleton.svelte";
|
||||
export { default as Spinner } from "./spinner.svelte";
|
||||
export { default as ScrollArea } from "./scroll-area.svelte";
|
||||
export { default as Textarea } from "./textarea.svelte";
|
||||
export * as Icons from "./icons.js";
|
||||
export * as Card from "./card/index.js";
|
||||
export { Accordion, Label, Select, Separator, Switch, Tabs, Tooltip } from "./primitives.js";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
export let maxHeight = "100%";
|
||||
export let element = null;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
class={`scroll-area ${className}`.trim()}
|
||||
style={`max-height:${maxHeight};`}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script>
|
||||
export let value = "";
|
||||
export let rows = 3;
|
||||
export let disabled = false;
|
||||
export let placeholder = "";
|
||||
export let maxlength = undefined;
|
||||
export let ariaLabel = "";
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<textarea
|
||||
class={`textarea ${className}`.trim()}
|
||||
bind:value
|
||||
{rows}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
{maxlength}
|
||||
aria-label={ariaLabel || placeholder}
|
||||
on:input
|
||||
on:keydown
|
||||
{...$$restProps}
|
||||
></textarea>
|
||||
@@ -22,6 +22,7 @@ export const APP_SECTION_PATHS = {
|
||||
home: "/home",
|
||||
invite: "/invite",
|
||||
devices: "/devices",
|
||||
support: "/support",
|
||||
settings: "/settings",
|
||||
admin: "/admin",
|
||||
};
|
||||
@@ -33,6 +34,7 @@ export const ADMIN_SECTIONS = new Set([
|
||||
"ads",
|
||||
"broadcast",
|
||||
"logs",
|
||||
"support",
|
||||
"tariffs",
|
||||
"appearance",
|
||||
"settings",
|
||||
|
||||
@@ -2,7 +2,24 @@ import { LANGUAGE_LABELS } from "./constants.js";
|
||||
import { formatTemplate, formatFraction, roundToHalf } from "./formatters.js";
|
||||
import { unitPluralBucket } from "./plurals.js";
|
||||
|
||||
export function createI18n({ messages = {}, defaultLang = "ru", getLang = null } = {}) {
|
||||
export function createI18n({
|
||||
messages: initialMessages = {},
|
||||
defaultLang = "ru",
|
||||
getLang = null,
|
||||
} = {}) {
|
||||
const messages = {};
|
||||
|
||||
function mergeMessages(nextMessages = {}) {
|
||||
if (!nextMessages || typeof nextMessages !== "object") return messages;
|
||||
for (const [lang, bucket] of Object.entries(nextMessages)) {
|
||||
if (!bucket || typeof bucket !== "object") continue;
|
||||
messages[lang] = { ...(messages[lang] || {}), ...bucket };
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
mergeMessages(initialMessages);
|
||||
|
||||
function normalizeLangCode(lang) {
|
||||
const key = String(lang || "")
|
||||
.trim()
|
||||
@@ -45,7 +62,7 @@ export function createI18n({ messages = {}, defaultLang = "ru", getLang = null }
|
||||
return t(`wa_sub_term_${unit}_${bucket}`);
|
||||
}
|
||||
|
||||
return { normalizeLangCode, t, currentLang, languageName, termUnitLabel };
|
||||
return { normalizeLangCode, t, currentLang, languageName, termUnitLabel, mergeMessages };
|
||||
}
|
||||
|
||||
export { formatFraction, roundToHalf };
|
||||
|
||||
@@ -66,6 +66,150 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
premium_traffic: { state: "none" },
|
||||
},
|
||||
];
|
||||
const supportTickets = [
|
||||
{
|
||||
ticket_id: 42,
|
||||
user_id: 100200300,
|
||||
subject: "Не подключается профиль на телефоне",
|
||||
category: "technical",
|
||||
priority: "high",
|
||||
status: "awaiting_admin",
|
||||
unread_user_count: 0,
|
||||
unread_admin_count: 2,
|
||||
last_message_at: new Date(Date.now() - 18 * 60000).toISOString(),
|
||||
created_at: new Date(Date.now() - 2 * 3600000).toISOString(),
|
||||
user: adminUsers[0],
|
||||
},
|
||||
{
|
||||
ticket_id: 43,
|
||||
user_id: 100200300,
|
||||
subject: "Вопрос по оплате подписки",
|
||||
category: "billing",
|
||||
priority: "normal",
|
||||
status: "awaiting_user",
|
||||
unread_user_count: 1,
|
||||
unread_admin_count: 0,
|
||||
last_message_at: new Date(Date.now() - 4 * 3600000).toISOString(),
|
||||
created_at: new Date(Date.now() - 6 * 3600000).toISOString(),
|
||||
user: adminUsers[0],
|
||||
},
|
||||
{
|
||||
ticket_id: 41,
|
||||
user_id: 100200300,
|
||||
subject: "Закрытый вопрос по старому профилю",
|
||||
category: "technical",
|
||||
priority: "low",
|
||||
status: "closed",
|
||||
unread_user_count: 0,
|
||||
unread_admin_count: 0,
|
||||
last_message_at: new Date(Date.now() - 4 * 86400000).toISOString(),
|
||||
created_at: new Date(Date.now() - 6 * 86400000).toISOString(),
|
||||
closed_at: new Date(Date.now() - 4 * 86400000).toISOString(),
|
||||
user: adminUsers[0],
|
||||
},
|
||||
];
|
||||
function supportCounts(items = supportTickets) {
|
||||
const byStatus = { open: 0, awaiting_admin: 0, awaiting_user: 0, resolved: 0 };
|
||||
for (const item of items) {
|
||||
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
|
||||
}
|
||||
const closed = (byStatus.closed || 0) + (byStatus.resolved || 0);
|
||||
const active = items.length - closed;
|
||||
return { ...byStatus, active, closed, total: items.length };
|
||||
}
|
||||
function filterSupportTickets(items, params) {
|
||||
let out = [...items];
|
||||
const status = params.get("status");
|
||||
if (status === "active")
|
||||
out = out.filter((item) => !["closed", "resolved"].includes(item.status));
|
||||
else if (status === "closed")
|
||||
out = out.filter((item) => ["closed", "resolved"].includes(item.status));
|
||||
else if (status) out = out.filter((item) => item.status === status);
|
||||
const priority = params.get("priority");
|
||||
if (priority) out = out.filter((item) => item.priority === priority);
|
||||
const category = params.get("category");
|
||||
if (category) out = out.filter((item) => item.category === category);
|
||||
const search = (params.get("search") || "").trim().toLowerCase();
|
||||
if (search) {
|
||||
out = out.filter((item) =>
|
||||
[item.subject, item.user?.username, item.user?.email, String(item.ticket_id)]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(search))
|
||||
);
|
||||
}
|
||||
const sort = params.get("sort") || "updated_desc";
|
||||
const priorityRank = { urgent: 4, high: 3, normal: 2, low: 1 };
|
||||
out.sort((a, b) => {
|
||||
if (sort === "importance_desc") {
|
||||
return (
|
||||
(priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0) ||
|
||||
new Date(b.last_message_at || b.created_at) - new Date(a.last_message_at || a.created_at)
|
||||
);
|
||||
}
|
||||
if (sort === "updated_asc") {
|
||||
return (
|
||||
new Date(a.last_message_at || a.created_at) - new Date(b.last_message_at || b.created_at)
|
||||
);
|
||||
}
|
||||
if (sort === "created_desc") return new Date(b.created_at) - new Date(a.created_at);
|
||||
if (sort === "created_asc") return new Date(a.created_at) - new Date(b.created_at);
|
||||
return (
|
||||
new Date(b.last_message_at || b.created_at) - new Date(a.last_message_at || a.created_at)
|
||||
);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
const supportMessages = {
|
||||
42: [
|
||||
{
|
||||
message_id: 1,
|
||||
ticket_id: 42,
|
||||
author_role: "user",
|
||||
author_user_id: 100200300,
|
||||
author_name: "Анна Смирнова",
|
||||
body: "После обновления приложения профиль перестал подключаться. Ошибка появляется сразу после импорта ссылки.",
|
||||
created_at: new Date(Date.now() - 2 * 3600000).toISOString(),
|
||||
},
|
||||
{
|
||||
message_id: 2,
|
||||
ticket_id: 42,
|
||||
author_role: "admin",
|
||||
author_user_id: 1,
|
||||
author_name: "Мария, поддержка",
|
||||
body: "Проверили подписку, она активна. Попробуйте удалить старый профиль и импортировать ссылку ещё раз.",
|
||||
created_at: new Date(Date.now() - 90 * 60000).toISOString(),
|
||||
},
|
||||
{
|
||||
message_id: 3,
|
||||
ticket_id: 42,
|
||||
author_role: "user",
|
||||
author_user_id: 100200300,
|
||||
author_name: "Анна Смирнова",
|
||||
body: "Сделал так, но теперь вижу timeout. Телефон iPhone, сеть домашний Wi‑Fi.",
|
||||
created_at: new Date(Date.now() - 18 * 60000).toISOString(),
|
||||
},
|
||||
],
|
||||
43: [
|
||||
{
|
||||
message_id: 4,
|
||||
ticket_id: 43,
|
||||
author_role: "user",
|
||||
author_user_id: 100200300,
|
||||
author_name: "Анна Смирнова",
|
||||
body: "Оплата прошла, но срок подписки не изменился.",
|
||||
created_at: new Date(Date.now() - 6 * 3600000).toISOString(),
|
||||
},
|
||||
{
|
||||
message_id: 5,
|
||||
ticket_id: 43,
|
||||
author_role: "admin",
|
||||
author_user_id: 2,
|
||||
author_name: "Иван, поддержка",
|
||||
body: "Платёж нашли и применили вручную. Проверьте, пожалуйста, дату окончания подписки.",
|
||||
created_at: new Date(Date.now() - 4 * 3600000).toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const mockAdminDailySeries = (() => {
|
||||
const days = 730;
|
||||
const out = [];
|
||||
@@ -331,8 +475,134 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
},
|
||||
],
|
||||
};
|
||||
if (cleanPath === "/admin/support/stats") {
|
||||
return {
|
||||
ok: true,
|
||||
stats: { ...supportCounts(), total_unread_admin: 2 },
|
||||
};
|
||||
}
|
||||
if (cleanPath === "/admin/support/tickets") {
|
||||
const params = new URLSearchParams(String(path || "").split("?")[1] || "");
|
||||
const tickets = filterSupportTickets(supportTickets, params);
|
||||
return { ok: true, tickets: clone(tickets), total: tickets.length };
|
||||
}
|
||||
if (cleanPath.startsWith("/admin/support/tickets/")) {
|
||||
const parts = cleanPath.split("/");
|
||||
const ticketId = Number(parts[4]);
|
||||
const ticket = clone(
|
||||
supportTickets.find((item) => item.ticket_id === ticketId) || supportTickets[0]
|
||||
);
|
||||
if (parts[5] === "messages") {
|
||||
return {
|
||||
ok: true,
|
||||
ticket,
|
||||
message: {
|
||||
message_id: Date.now(),
|
||||
ticket_id: ticket.ticket_id,
|
||||
author_role: "admin",
|
||||
author_user_id: 1,
|
||||
author_name: "Мария, поддержка",
|
||||
body: JSON.parse(options?.body || "{}")?.body || "",
|
||||
is_internal_note: Boolean(JSON.parse(options?.body || "{}")?.is_internal_note),
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (String(options.method || "GET").toUpperCase() === "PATCH") {
|
||||
return { ok: true, ticket: { ...ticket, ...(JSON.parse(options?.body || "{}") || {}) } };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
ticket,
|
||||
messages: clone([
|
||||
...(supportMessages[ticket.ticket_id] || []),
|
||||
{
|
||||
message_id: 99,
|
||||
ticket_id: ticket.ticket_id,
|
||||
author_role: "admin",
|
||||
author_user_id: 1,
|
||||
author_name: "Мария, поддержка",
|
||||
body: "Внутренняя заметка для команды: проверить последние логи панели перед ответом.",
|
||||
is_internal_note: true,
|
||||
created_at: new Date(Date.now() - 12 * 60000).toISOString(),
|
||||
},
|
||||
]),
|
||||
user_snapshot: {
|
||||
user_id: ticket.user_id,
|
||||
name: "Анна Смирнова",
|
||||
username: "anna_ops",
|
||||
email: "anna@example.com",
|
||||
tariff: "Standard",
|
||||
panel_status: "ACTIVE",
|
||||
remaining: "20 д. 4 ч.",
|
||||
regular_traffic: "12 GB / 500 GB",
|
||||
premium_traffic: "4 GB / 25 GB",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (cleanPath.startsWith("/admin/"))
|
||||
return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
|
||||
if (
|
||||
cleanPath === "/support/tickets" &&
|
||||
String(options.method || "GET").toUpperCase() === "POST"
|
||||
) {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(options?.body || "{}");
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
ticket: {
|
||||
ticket_id: 44,
|
||||
user_id: 100200300,
|
||||
subject: payload.subject || "Новое обращение",
|
||||
category: payload.category || "other",
|
||||
priority: payload.priority || "normal",
|
||||
status: "awaiting_admin",
|
||||
unread_user_count: 0,
|
||||
unread_admin_count: 1,
|
||||
last_message_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (cleanPath === "/support/tickets") {
|
||||
const params = new URLSearchParams(String(path || "").split("?")[1] || "");
|
||||
const tickets = filterSupportTickets(supportTickets, params);
|
||||
return {
|
||||
ok: true,
|
||||
tickets: clone(tickets),
|
||||
total: tickets.length,
|
||||
counts: supportCounts(),
|
||||
};
|
||||
}
|
||||
if (cleanPath.startsWith("/support/tickets/")) {
|
||||
const parts = cleanPath.split("/");
|
||||
const ticketId = Number(parts[3]);
|
||||
const ticket = clone(
|
||||
supportTickets.find((item) => item.ticket_id === ticketId) || supportTickets[0]
|
||||
);
|
||||
if (parts[4] === "read") return { ok: true };
|
||||
if (parts[4] === "messages") {
|
||||
return {
|
||||
ok: true,
|
||||
ticket,
|
||||
message: {
|
||||
message_id: Date.now(),
|
||||
ticket_id: ticket.ticket_id,
|
||||
author_role: "user",
|
||||
author_user_id: 100200300,
|
||||
author_name: "Анна Смирнова",
|
||||
body: JSON.parse(options?.body || "{}")?.body || "",
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: true, ticket, messages: clone(supportMessages[ticket.ticket_id] || []) };
|
||||
}
|
||||
if (cleanPath === "/support/unread") return { ok: true, unread: 1 };
|
||||
if (path === "/me") return clone(DEV_MOCK.data);
|
||||
if (path === "/auth/email/request") return { ok: true };
|
||||
if (path === "/auth/email/verify" || path === "/auth/email/magic") {
|
||||
|
||||
@@ -7,6 +7,7 @@ export function normalizeSection(value) {
|
||||
if (
|
||||
section === "invite" ||
|
||||
section === "devices" ||
|
||||
section === "support" ||
|
||||
section === "settings" ||
|
||||
section === "admin"
|
||||
) {
|
||||
@@ -22,6 +23,7 @@ export function sectionFromPath(pathname) {
|
||||
.replace(/\/+$/, "");
|
||||
if (!normalizedPath || normalizedPath === "/") return "home";
|
||||
if (normalizedPath === "/admin" || normalizedPath.startsWith("/admin/")) return "admin";
|
||||
if (normalizedPath === "/support" || normalizedPath.startsWith("/support/")) return "support";
|
||||
const section = normalizedPath.startsWith("/") ? normalizedPath.slice(1) : normalizedPath;
|
||||
return normalizeSection(section);
|
||||
}
|
||||
@@ -43,6 +45,22 @@ export function adminUserIdFromPath(pathname) {
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
export function supportTicketIdFromPath(pathname) {
|
||||
const normalized = String(pathname || "")
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, "");
|
||||
const m = normalized.match(/^\/support\/(\d+)$/);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
export function adminSupportTicketIdFromPath(pathname) {
|
||||
const normalized = String(pathname || "")
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, "");
|
||||
const m = normalized.match(/^\/admin\/support\/(\d+)$/);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
export function syncSectionPath(section, replace = false, adminSection = null, adminUserId = null) {
|
||||
if (window.location.protocol === "file:") return;
|
||||
const normalized = normalizeSection(section);
|
||||
@@ -51,7 +69,11 @@ export function syncSectionPath(section, replace = false, adminSection = null, a
|
||||
const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats";
|
||||
const uid =
|
||||
adminUserId ?? (adm === "users" ? adminUserIdFromPath(window.location.pathname) : null);
|
||||
targetPath = adm === "users" && uid ? `/admin/users/${uid}` : `/admin/${adm}`;
|
||||
const supportTicketId =
|
||||
adm === "support" ? adminSupportTicketIdFromPath(window.location.pathname) : null;
|
||||
if (adm === "users" && uid) targetPath = `/admin/users/${uid}`;
|
||||
else if (adm === "support" && supportTicketId) targetPath = `/admin/support/${supportTicketId}`;
|
||||
else targetPath = `/admin/${adm}`;
|
||||
}
|
||||
if (window.location.pathname === targetPath) return;
|
||||
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createSupportStore({ api, t, showToast }) {
|
||||
const state = writable({
|
||||
tickets: [],
|
||||
openedTicketId: null,
|
||||
openedTicket: null,
|
||||
messages: [],
|
||||
unreadCount: 0,
|
||||
unreadLoaded: false,
|
||||
unreadLoading: false,
|
||||
counts: { active: 0, closed: 0, awaiting_admin: 0, awaiting_user: 0, open: 0, total: 0 },
|
||||
loading: false,
|
||||
detailLoading: false,
|
||||
sending: false,
|
||||
creating: false,
|
||||
statusFilter: "active",
|
||||
polling: false,
|
||||
});
|
||||
|
||||
let pollTimer = null;
|
||||
let pollIncludeList = false;
|
||||
let listRequestSeq = 0;
|
||||
let listPromise = null;
|
||||
let listPromiseKey = "";
|
||||
let unreadPromise = null;
|
||||
|
||||
function hydrateUnread(value) {
|
||||
const next = Math.max(0, Number(value || 0));
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
unreadCount: next,
|
||||
unreadLoaded: true,
|
||||
unreadLoading: false,
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadList(options = {}) {
|
||||
let filter = "all";
|
||||
let hasTickets = false;
|
||||
state.update((s) => {
|
||||
filter = s.statusFilter;
|
||||
hasTickets = Boolean(s.tickets?.length);
|
||||
return s;
|
||||
});
|
||||
const requestKey = filter || "all";
|
||||
if (!options.force && listPromise && listPromiseKey === requestKey) return listPromise;
|
||||
|
||||
const requestId = ++listRequestSeq;
|
||||
const showLoading = !options.silent && (options.showLoading || !hasTickets);
|
||||
if (showLoading) state.update((s) => ({ ...s, loading: true }));
|
||||
|
||||
let promise;
|
||||
promise = (async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: "50", offset: "0" });
|
||||
if (filter && filter !== "all") params.set("status", filter);
|
||||
const res = await api(`/support/tickets?${params.toString()}`);
|
||||
if (requestId !== listRequestSeq) return res;
|
||||
if (res?.ok)
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
tickets: res.tickets || [],
|
||||
counts: res.counts || s.counts,
|
||||
}));
|
||||
else if (res?.error) showToast(res.message || res.error);
|
||||
return res;
|
||||
} finally {
|
||||
if (requestId === listRequestSeq) {
|
||||
state.update((s) => (s.loading ? { ...s, loading: false } : s));
|
||||
}
|
||||
if (listPromise === promise) {
|
||||
listPromise = null;
|
||||
listPromiseKey = "";
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
listPromise = promise;
|
||||
listPromiseKey = requestKey;
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function refreshCurrentTicket(ticketId) {
|
||||
const id = Number(ticketId);
|
||||
if (!id) return;
|
||||
try {
|
||||
const res = await api(`/support/tickets/${id}`);
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedTicket: res.ticket,
|
||||
messages: res.messages || [],
|
||||
}));
|
||||
await markRead(id);
|
||||
}
|
||||
return res;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function createTicket(payload) {
|
||||
state.update((s) => ({ ...s, creating: true }));
|
||||
try {
|
||||
const res = await api("/support/tickets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res?.ok) throw res;
|
||||
state.update((s) => ({ ...s, statusFilter: "active" }));
|
||||
await loadList({ silent: true, force: true });
|
||||
await openTicket(res.ticket.ticket_id);
|
||||
return res.ticket;
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_support_create_failed"));
|
||||
return null;
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, creating: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function openTicket(ticketId, opts = {}) {
|
||||
const id = Number(ticketId);
|
||||
if (!id) return;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedTicketId: id,
|
||||
openedTicket: s.openedTicket?.ticket_id === id ? s.openedTicket : null,
|
||||
messages: s.openedTicket?.ticket_id === id ? s.messages : [],
|
||||
detailLoading: true,
|
||||
}));
|
||||
if (!opts.skipPush && typeof window !== "undefined" && window.location.protocol !== "file:") {
|
||||
const target = `/support/${id}`;
|
||||
if (window.location.pathname !== target) {
|
||||
window.history.pushState(
|
||||
null,
|
||||
"",
|
||||
`${target}${window.location.search}${window.location.hash}`
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await api(`/support/tickets/${id}`);
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedTicket: res.ticket,
|
||||
messages: res.messages || [],
|
||||
}));
|
||||
await markRead(id);
|
||||
} else {
|
||||
showToast(res?.message || res?.error || "not_found");
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, detailLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function closeTicketView(opts = {}) {
|
||||
state.update((s) => ({ ...s, openedTicketId: null, openedTicket: null, messages: [] }));
|
||||
if (!opts.skipPush && typeof window !== "undefined" && window.location.protocol !== "file:") {
|
||||
if (window.location.pathname.startsWith("/support/")) {
|
||||
window.history.pushState(
|
||||
null,
|
||||
"",
|
||||
`/support${window.location.search}${window.location.hash}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReply(body) {
|
||||
let ticketId = null;
|
||||
state.update((s) => {
|
||||
ticketId = s.openedTicketId;
|
||||
return { ...s, sending: true };
|
||||
});
|
||||
if (!ticketId) return;
|
||||
try {
|
||||
const res = await api(`/support/tickets/${ticketId}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
if (!res?.ok) throw res;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedTicket: res.ticket || s.openedTicket,
|
||||
messages: [...s.messages, res.message],
|
||||
}));
|
||||
await refreshUnread();
|
||||
await loadList({ silent: true, force: true });
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_support_send_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, sending: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(ticketId = null) {
|
||||
const id =
|
||||
ticketId ||
|
||||
(() => {
|
||||
let current = null;
|
||||
state.update((s) => {
|
||||
current = s.openedTicketId;
|
||||
return s;
|
||||
});
|
||||
return current;
|
||||
})();
|
||||
if (!id) return;
|
||||
await api(`/support/tickets/${id}/read`, { method: "POST", body: "{}" });
|
||||
await refreshUnread();
|
||||
}
|
||||
|
||||
async function refreshUnread() {
|
||||
if (unreadPromise) return unreadPromise;
|
||||
state.update((s) => ({ ...s, unreadLoading: true }));
|
||||
unreadPromise = (async () => {
|
||||
try {
|
||||
const res = await api("/support/unread");
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
unreadCount: Math.max(0, Number(res.unread || 0)),
|
||||
unreadLoaded: true,
|
||||
}));
|
||||
}
|
||||
return res;
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, unreadLoading: false }));
|
||||
unreadPromise = null;
|
||||
}
|
||||
})();
|
||||
return unreadPromise;
|
||||
}
|
||||
|
||||
function setStatusFilter(status) {
|
||||
state.update((s) => ({ ...s, statusFilter: status || "all" }));
|
||||
loadList({ force: true, showLoading: true });
|
||||
}
|
||||
|
||||
function startPolling(options = {}) {
|
||||
const includeList = options.includeList !== false;
|
||||
pollIncludeList = pollIncludeList || includeList;
|
||||
if (pollTimer || typeof window === "undefined") return;
|
||||
state.update((s) => ({ ...s, polling: true }));
|
||||
const tick = async () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
await refreshUnread();
|
||||
if (!pollIncludeList) return;
|
||||
let opened = null;
|
||||
state.update((s) => {
|
||||
opened = s.openedTicketId;
|
||||
return s;
|
||||
});
|
||||
if (opened) await refreshCurrentTicket(opened);
|
||||
else await loadList({ silent: true });
|
||||
}
|
||||
};
|
||||
pollTimer = window.setInterval(tick, 15000);
|
||||
document.addEventListener("visibilitychange", tick);
|
||||
}
|
||||
|
||||
function closePolling() {
|
||||
if (pollTimer) window.clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
pollIncludeList = false;
|
||||
state.update((s) => ({ ...s, polling: false }));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
update: state.update,
|
||||
loadList,
|
||||
hydrateUnread,
|
||||
createTicket,
|
||||
openTicket,
|
||||
closeTicketView,
|
||||
sendReply,
|
||||
markRead,
|
||||
refreshUnread,
|
||||
setStatusFilter,
|
||||
startPolling,
|
||||
closePolling,
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import "./styles.css";
|
||||
async function loadBootstrap() {
|
||||
if (document.getElementById("webapp-config")) return;
|
||||
try {
|
||||
const response = await fetch("/api/bootstrap", {
|
||||
const response = await fetch("/api/bootstrap?i18n_scope=webapp", {
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
@@ -31,6 +31,7 @@ const target = document.getElementById("app");
|
||||
|
||||
if (target) {
|
||||
loadBootstrap().finally(() => {
|
||||
target.replaceChildren();
|
||||
mount(App, { target });
|
||||
});
|
||||
}
|
||||
|
||||
+1748
-10
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1466,20 +1466,6 @@ a {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.attention-dot {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
transform: translate(50%, -50%);
|
||||
border-radius: 999px;
|
||||
background: #ff4b4b;
|
||||
box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75);
|
||||
animation: attention-pulse 1.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-attention-dot {
|
||||
top: 6px;
|
||||
right: 10px;
|
||||
@@ -1490,20 +1476,6 @@ a {
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
@keyframes attention-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgba(255, 75, 75, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 75, 75, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed !important;
|
||||
left: var(--bottom-nav-left);
|
||||
@@ -1514,9 +1486,11 @@ a {
|
||||
bottom: max(var(--bottom-nav-offset), env(safe-area-inset-bottom));
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(var(--bottom-nav-visible-items, 3), minmax(0, 1fr));
|
||||
grid-auto-flow: column;
|
||||
gap: 2px;
|
||||
min-height: 64px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--nav-bg);
|
||||
@@ -1525,7 +1499,7 @@ a {
|
||||
}
|
||||
|
||||
.bottom-nav-devices {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(var(--bottom-nav-visible-items, 4), minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.bottom-nav.static {
|
||||
@@ -1534,6 +1508,7 @@ a {
|
||||
|
||||
.bottom-nav button {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 4px;
|
||||
@@ -1542,16 +1517,77 @@ a {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
.bottom-nav button.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.bottom-nav .bottom-nav-label {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.rail-admin-entry {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.bottom-nav.bottom-nav-many {
|
||||
min-height: 58px;
|
||||
}
|
||||
|
||||
.bottom-nav.bottom-nav-many button {
|
||||
gap: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.bottom-nav.bottom-nav-many .bottom-nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bottom-nav.bottom-nav-many .nav-attention-dot {
|
||||
top: 8px;
|
||||
right: calc(50% - 18px);
|
||||
}
|
||||
|
||||
.bottom-nav.bottom-nav-many .nav-badge-floating {
|
||||
top: 6px;
|
||||
right: calc(50% - 24px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.bottom-nav {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.bottom-nav button {
|
||||
gap: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.bottom-nav .bottom-nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bottom-nav .nav-attention-dot {
|
||||
top: 8px;
|
||||
right: calc(50% - 18px);
|
||||
}
|
||||
|
||||
.bottom-nav .nav-badge-floating {
|
||||
top: 6px;
|
||||
right: calc(50% - 24px);
|
||||
}
|
||||
}
|
||||
|
||||
.home-layout {
|
||||
display: grid;
|
||||
min-height: calc(100dvh - 34px);
|
||||
@@ -1975,6 +2011,10 @@ a {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.preview-phone .bottom-nav .bottom-nav-label {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.preview-phone .card {
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -2247,10 +2287,14 @@ a {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr !important;
|
||||
grid-template-rows: none !important;
|
||||
grid-auto-flow: row !important;
|
||||
grid-auto-rows: auto !important;
|
||||
grid-auto-columns: 1fr !important;
|
||||
align-content: start !important;
|
||||
gap: 2px !important;
|
||||
padding: 28px 14px !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
border: 0 !important;
|
||||
border-right: 1px solid var(--border) !important;
|
||||
border-radius: 0 !important;
|
||||
@@ -2271,7 +2315,7 @@ a {
|
||||
align-items: center !important;
|
||||
justify-items: start !important;
|
||||
gap: 12px !important;
|
||||
padding: 12px 14px !important;
|
||||
padding: 12px 42px 12px 14px !important;
|
||||
border-radius: 10px !important;
|
||||
text-align: left !important;
|
||||
font-size: 13px !important;
|
||||
@@ -2281,6 +2325,7 @@ a {
|
||||
background 0.12s ease,
|
||||
color 0.12s ease,
|
||||
border-color 0.12s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bottom-nav button > svg {
|
||||
@@ -2288,7 +2333,7 @@ a {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.bottom-nav button > span {
|
||||
.bottom-nav .bottom-nav-label {
|
||||
text-align: left !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600;
|
||||
@@ -2305,9 +2350,11 @@ a {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.bottom-nav .nav-attention-dot {
|
||||
top: 14px;
|
||||
.bottom-nav .nav-attention-dot,
|
||||
.bottom-nav .nav-badge-floating {
|
||||
top: 50%;
|
||||
right: 14px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.bottom-nav .rail-admin-entry {
|
||||
@@ -2393,6 +2440,7 @@ a {
|
||||
@media (min-width: 1024px) {
|
||||
.bottom-nav .rail-brand {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 12px 18px;
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import {
|
||||
Gift,
|
||||
Home,
|
||||
LifeBuoy,
|
||||
Settings as SettingsIcon,
|
||||
Shield,
|
||||
Smartphone,
|
||||
} from "$components/ui/icons.js";
|
||||
import { AttentionDot } from "$components/ui/index.js";
|
||||
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
|
||||
@@ -13,51 +15,107 @@
|
||||
export let brand = {};
|
||||
export let brandTitle = "";
|
||||
export let devicesEnabled = false;
|
||||
export let supportEnabled = true;
|
||||
export let supportUnreadCount = 0;
|
||||
export let supportUnreadLoading = false;
|
||||
export let supportUnreadLoaded = false;
|
||||
export let hasUnlinkedIdentity = false;
|
||||
export let isAdmin = false;
|
||||
export let onAdmin = () => {};
|
||||
export let onDevices = () => {};
|
||||
export let onHome = () => {};
|
||||
export let onInvite = () => {};
|
||||
export let onSupport = () => {};
|
||||
export let onSettings = () => {};
|
||||
export let t = (key) => key;
|
||||
|
||||
$: visibleNavItems = 3 + (devicesEnabled ? 1 : 0) + (supportEnabled ? 1 : 0);
|
||||
$: adminLabel = t("admin_nav_title", {}, "Админ-панель");
|
||||
</script>
|
||||
|
||||
<nav class:bottom-nav-devices={devicesEnabled} class="bottom-nav" aria-label={t("wa_navigation")}>
|
||||
<nav
|
||||
class:bottom-nav-devices={devicesEnabled}
|
||||
class:bottom-nav-many={visibleNavItems >= 5}
|
||||
class="bottom-nav"
|
||||
style={`--bottom-nav-visible-items: ${visibleNavItems}`}
|
||||
aria-label={t("wa_navigation")}
|
||||
>
|
||||
<div class="rail-brand" aria-hidden="true">
|
||||
<BrandMark {brand} />
|
||||
<strong>{brandTitle}</strong>
|
||||
</div>
|
||||
<button class:active={activeTab === "home"} type="button" onclick={onHome}>
|
||||
<button
|
||||
class:active={activeTab === "home"}
|
||||
type="button"
|
||||
aria-label={t("wa_nav_home")}
|
||||
title={t("wa_nav_home")}
|
||||
onclick={onHome}
|
||||
>
|
||||
<Home size={21} />
|
||||
<span>{t("wa_nav_home")}</span>
|
||||
<span class="bottom-nav-label">{t("wa_nav_home")}</span>
|
||||
</button>
|
||||
<button class:active={activeTab === "invite"} type="button" onclick={onInvite}>
|
||||
<button
|
||||
class:active={activeTab === "invite"}
|
||||
type="button"
|
||||
aria-label={t("wa_nav_bonuses")}
|
||||
title={t("wa_nav_bonuses")}
|
||||
onclick={onInvite}
|
||||
>
|
||||
<Gift size={21} />
|
||||
<span>{t("wa_nav_bonuses")}</span>
|
||||
<span class="bottom-nav-label">{t("wa_nav_bonuses")}</span>
|
||||
</button>
|
||||
{#if devicesEnabled}
|
||||
<button class:active={activeTab === "devices"} type="button" onclick={onDevices}>
|
||||
<button
|
||||
class:active={activeTab === "devices"}
|
||||
type="button"
|
||||
aria-label={t("wa_nav_devices")}
|
||||
title={t("wa_nav_devices")}
|
||||
onclick={onDevices}
|
||||
>
|
||||
<Smartphone size={21} />
|
||||
<span>{t("wa_nav_devices")}</span>
|
||||
<span class="bottom-nav-label">{t("wa_nav_devices")}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if supportEnabled}
|
||||
<button
|
||||
class:active={activeTab === "support"}
|
||||
class="attention-wrap"
|
||||
type="button"
|
||||
aria-label={t("wa_nav_support")}
|
||||
title={t("wa_nav_support")}
|
||||
onclick={onSupport}
|
||||
>
|
||||
{#if supportUnreadCount || (supportUnreadLoading && !supportUnreadLoaded)}
|
||||
<AttentionDot class="nav-attention-dot" />
|
||||
{/if}
|
||||
<LifeBuoy size={21} />
|
||||
<span class="bottom-nav-label">{t("wa_nav_support")}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class:active={activeTab === "settings"}
|
||||
class="attention-wrap"
|
||||
type="button"
|
||||
aria-label={t("wa_nav_settings")}
|
||||
title={t("wa_nav_settings")}
|
||||
onclick={onSettings}
|
||||
>
|
||||
{#if hasUnlinkedIdentity}
|
||||
<span class="attention-dot nav-attention-dot" aria-hidden="true"></span>
|
||||
<AttentionDot class="nav-attention-dot" />
|
||||
{/if}
|
||||
<SettingsIcon size={21} />
|
||||
<span>{t("wa_nav_settings")}</span>
|
||||
<span class="bottom-nav-label">{t("wa_nav_settings")}</span>
|
||||
</button>
|
||||
{#if isAdmin}
|
||||
<button class="rail-admin-entry" type="button" onclick={onAdmin}>
|
||||
<button
|
||||
class="rail-admin-entry"
|
||||
type="button"
|
||||
aria-label={adminLabel}
|
||||
title={adminLabel}
|
||||
onclick={onAdmin}
|
||||
>
|
||||
<Shield size={21} />
|
||||
<span>{t("admin_nav_title", {}, "Админ-панель")}</span>
|
||||
<span class="bottom-nav-label">{adminLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
@@ -7,18 +7,23 @@
|
||||
export let brand = {};
|
||||
export let brandTitle;
|
||||
export let devicesEnabled;
|
||||
export let supportEnabled = true;
|
||||
export let supportUnreadCount = 0;
|
||||
export let supportUnreadLoading = false;
|
||||
export let supportUnreadLoaded = false;
|
||||
export let hasUnlinkedIdentity;
|
||||
export let isAdmin;
|
||||
export let openAdminPanel;
|
||||
export let goDevices;
|
||||
export let goHome;
|
||||
export let goInvite;
|
||||
export let goSupport;
|
||||
export let goSettings;
|
||||
export let t;
|
||||
</script>
|
||||
|
||||
<div class="phone-screen" class:home-screen={screen === "home"}>
|
||||
{#if screen === "invite" || screen === "devices" || screen === "settings"}
|
||||
{#if screen === "invite" || screen === "devices" || screen === "support" || screen === "settings"}
|
||||
<header class="app-header accent-title">
|
||||
<div class="brand-row">
|
||||
<BrandMark {brand} />
|
||||
@@ -34,12 +39,17 @@
|
||||
{brand}
|
||||
{brandTitle}
|
||||
{devicesEnabled}
|
||||
{supportEnabled}
|
||||
{supportUnreadCount}
|
||||
{supportUnreadLoading}
|
||||
{supportUnreadLoaded}
|
||||
{hasUnlinkedIdentity}
|
||||
{isAdmin}
|
||||
onAdmin={openAdminPanel}
|
||||
onDevices={goDevices}
|
||||
onHome={goHome}
|
||||
onInvite={goInvite}
|
||||
onSupport={goSupport}
|
||||
onSettings={goSettings}
|
||||
{t}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import Button from "$components/ui/button.svelte";
|
||||
import Card from "$components/ui/card.svelte";
|
||||
import { AttentionDot } from "$components/ui/index.js";
|
||||
import { LanguageSelect } from "$components/patterns/webapp/index.js";
|
||||
|
||||
export let currentLang = "ru";
|
||||
@@ -95,7 +96,7 @@
|
||||
onclick={linkTelegramAccount}
|
||||
disabled={linkTelegramBusy}
|
||||
>
|
||||
<span class="attention-dot" aria-hidden="true"></span>
|
||||
<AttentionDot />
|
||||
<Send size={18} />
|
||||
{t("wa_settings_link_telegram_action")}
|
||||
</Button>
|
||||
@@ -127,7 +128,7 @@
|
||||
onclick={openLinkEmailDialog}
|
||||
disabled={linkEmailBusy}
|
||||
>
|
||||
<span class="attention-dot" aria-hidden="true"></span>
|
||||
<AttentionDot />
|
||||
<Mail size={21} />
|
||||
<span>
|
||||
<strong>{t("wa_settings_link_email_action")}</strong>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
<script>
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { fade, slide } from "svelte/transition";
|
||||
import { Check, ChevronsUpDown, LifeBuoy, MessageSquarePlus } from "$components/ui/icons.js";
|
||||
import Button from "$components/ui/button.svelte";
|
||||
import Card from "$components/ui/card.svelte";
|
||||
import { Skeleton } from "$components/ui/index.js";
|
||||
import { TicketCard } from "$components/patterns/webapp/index.js";
|
||||
import { Select, Tabs } from "$components/ui/primitives.js";
|
||||
|
||||
export let t = (key) => key;
|
||||
export let maxSubjectLength = 160;
|
||||
export let maxBodyLength = 4000;
|
||||
|
||||
const supportStore = getContext("supportStore");
|
||||
let subject = "";
|
||||
let body = "";
|
||||
let category = "other";
|
||||
let priority = "normal";
|
||||
let createOpen = false;
|
||||
|
||||
$: ({ tickets, loading, creating, statusFilter, counts } = $supportStore);
|
||||
$: categoryOptions = [
|
||||
{ value: "billing", label: t("wa_support_category_billing") },
|
||||
{ value: "technical", label: t("wa_support_category_technical") },
|
||||
{ value: "account", label: t("wa_support_category_account") },
|
||||
{ value: "other", label: t("wa_support_category_other") },
|
||||
];
|
||||
$: priorityOptions = [
|
||||
{ value: "normal", label: t("wa_support_priority_normal") },
|
||||
{ value: "high", label: t("wa_support_priority_high") },
|
||||
];
|
||||
$: statusTabs = [
|
||||
{
|
||||
value: "active",
|
||||
label: t("wa_support_filter_active", {}, "Активные"),
|
||||
count: counts?.active || 0,
|
||||
},
|
||||
{
|
||||
value: "awaiting_admin",
|
||||
label: t("wa_support_status_awaiting_admin", {}, "Ждет админа"),
|
||||
count: counts?.awaiting_admin || 0,
|
||||
},
|
||||
{
|
||||
value: "awaiting_user",
|
||||
label: t("wa_support_status_awaiting_user", {}, "Ждет пользователя"),
|
||||
count: counts?.awaiting_user || 0,
|
||||
},
|
||||
{
|
||||
value: "closed",
|
||||
label: t("wa_support_status_closed", {}, "Закрытые"),
|
||||
count: counts?.closed || 0,
|
||||
},
|
||||
];
|
||||
$: selectedCategory =
|
||||
categoryOptions.find((option) => option.value === category) || categoryOptions[0];
|
||||
$: selectedPriority =
|
||||
priorityOptions.find((option) => option.value === priority) || priorityOptions[0];
|
||||
|
||||
onMount(() => {
|
||||
supportStore.loadList();
|
||||
});
|
||||
|
||||
async function createTicket() {
|
||||
const ticket = await supportStore.createTicket({ subject, body, category, priority });
|
||||
if (ticket) {
|
||||
subject = "";
|
||||
body = "";
|
||||
category = "other";
|
||||
priority = "normal";
|
||||
createOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="content with-nav support-screen">
|
||||
<Card class="support-overview-card">
|
||||
<div class="support-heading-row">
|
||||
<span class="support-heading-icon" aria-hidden="true">
|
||||
<LifeBuoy size={42} />
|
||||
</span>
|
||||
<div class="support-heading-copy">
|
||||
<h1>{t("wa_support_title")}</h1>
|
||||
<p>{t("wa_support_subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class:active={createOpen}
|
||||
class="support-new-ticket-button"
|
||||
type="button"
|
||||
aria-expanded={createOpen}
|
||||
on:click={() => (createOpen = !createOpen)}
|
||||
>
|
||||
<span class="support-new-ticket-icon">
|
||||
<MessageSquarePlus size={20} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>{t("wa_support_new_ticket")}</strong>
|
||||
<small>{t("wa_support_contact_support")}</small>
|
||||
</span>
|
||||
<ChevronsUpDown size={18} />
|
||||
</button>
|
||||
|
||||
{#if createOpen}
|
||||
<div class="support-create-panel" in:slide={{ duration: 180 }} out:slide={{ duration: 140 }}>
|
||||
<div class="support-create-panel-inner" in:fade={{ duration: 140 }}>
|
||||
<label class="support-field">
|
||||
<span>{t("wa_support_subject")}</span>
|
||||
<input
|
||||
class="input"
|
||||
bind:value={subject}
|
||||
maxlength={maxSubjectLength}
|
||||
placeholder={t("wa_support_subject_placeholder")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="support-create-grid">
|
||||
<label class="support-field">
|
||||
<span>{t("wa_support_category")}</span>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={category}
|
||||
items={categoryOptions}
|
||||
onValueChange={(value) => (category = value)}
|
||||
>
|
||||
<Select.Trigger
|
||||
class="support-select-trigger"
|
||||
aria-label={t("wa_support_category")}
|
||||
>
|
||||
<span>{selectedCategory.label}</span>
|
||||
<ChevronsUpDown size={16} />
|
||||
</Select.Trigger>
|
||||
<Select.Content
|
||||
class="support-select-content"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
>
|
||||
<Select.Viewport class="support-select-viewport">
|
||||
{#each categoryOptions as option (option.value)}
|
||||
<Select.Item
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
class="support-select-item"
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<Check size={15} class="support-select-check" />
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</label>
|
||||
|
||||
<label class="support-field">
|
||||
<span>{t("wa_support_priority")}</span>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={priority}
|
||||
items={priorityOptions}
|
||||
onValueChange={(value) => (priority = value)}
|
||||
>
|
||||
<Select.Trigger
|
||||
class="support-select-trigger"
|
||||
aria-label={t("wa_support_priority")}
|
||||
>
|
||||
<span>{selectedPriority.label}</span>
|
||||
<ChevronsUpDown size={16} />
|
||||
</Select.Trigger>
|
||||
<Select.Content
|
||||
class="support-select-content"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
>
|
||||
<Select.Viewport class="support-select-viewport">
|
||||
{#each priorityOptions as option (option.value)}
|
||||
<Select.Item
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
class="support-select-item"
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<Check size={15} class="support-select-check" />
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="support-field">
|
||||
<span>{t("wa_support_message")}</span>
|
||||
<textarea
|
||||
class="textarea support-message-input"
|
||||
bind:value={body}
|
||||
maxlength={maxBodyLength}
|
||||
rows="5"
|
||||
placeholder={t("wa_support_message_placeholder")}
|
||||
></textarea>
|
||||
<small>{body.length}/{maxBodyLength}</small>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
class="wide support-submit-button"
|
||||
size="lg"
|
||||
disabled={creating || !subject.trim() || !body.trim()}
|
||||
onclick={createTicket}
|
||||
>
|
||||
<MessageSquarePlus size={18} />
|
||||
{creating ? t("wa_support_creating") : t("wa_support_create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card class="support-list-card">
|
||||
<Tabs.Root
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => supportStore.setStatusFilter(value || "all")}
|
||||
class="support-status-tabs"
|
||||
>
|
||||
<Tabs.List class="support-status-tabs-list" aria-label={t("wa_support_filter_label")}>
|
||||
{#each statusTabs as tab (tab.value)}
|
||||
<Tabs.Trigger value={tab.value} class="support-status-tabs-trigger">
|
||||
<span>{tab.label}</span>
|
||||
<b>{tab.count}</b>
|
||||
</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
|
||||
{#if loading}
|
||||
<div class="support-user-list-skeleton" aria-label={t("wa_loading")}>
|
||||
{#each Array(5) as _, index (index)}
|
||||
<article class="support-user-ticket-skeleton">
|
||||
<span class="support-user-ticket-skeleton-main">
|
||||
<Skeleton variant="title" width="min(420px, 76%)" />
|
||||
<Skeleton variant="short" width="min(260px, 58%)" />
|
||||
</span>
|
||||
<span class="support-user-ticket-skeleton-side">
|
||||
<Skeleton variant="badge" width="92px" />
|
||||
<Skeleton variant="tiny" width="64px" />
|
||||
</span>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !tickets.length}
|
||||
<div class="support-empty-state" in:fade={{ duration: 180 }}>
|
||||
<MessageSquarePlus size={34} />
|
||||
<strong>{t("wa_support_no_open_tickets")}</strong>
|
||||
<small>{t("wa_support_empty_hint")}</small>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="ticket-list">
|
||||
{#each tickets as ticket}
|
||||
<TicketCard {ticket} {t} onOpen={(item) => supportStore.openTicket(item.ticket_id)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
</main>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script>
|
||||
import { afterUpdate, getContext, tick } from "svelte";
|
||||
import { Badge, Button, ScrollArea, Skeleton } from "$components/ui/index.js";
|
||||
import Card from "$components/ui/card.svelte";
|
||||
import { ArrowLeft } from "$components/ui/icons.js";
|
||||
import { TicketComposer, TicketMessageBubble } from "$components/patterns/webapp/index.js";
|
||||
|
||||
export let t = (key) => key;
|
||||
export let maxBodyLength = 4000;
|
||||
export let brand = {};
|
||||
export let userAvatarUrl = "";
|
||||
export let userInitials = "";
|
||||
|
||||
const supportStore = getContext("supportStore");
|
||||
let reply = "";
|
||||
let messagesScrollEl;
|
||||
let lastMessageKey = "";
|
||||
|
||||
$: ({ openedTicket, messages, detailLoading, sending } = $supportStore);
|
||||
$: closed = ["resolved", "closed"].includes(openedTicket?.status);
|
||||
|
||||
async function send(body) {
|
||||
await supportStore.sendReply(body);
|
||||
reply = "";
|
||||
}
|
||||
|
||||
function scrollMessagesToBottom() {
|
||||
if (!messagesScrollEl) return;
|
||||
const scroll = () => {
|
||||
messagesScrollEl.scrollTop = messagesScrollEl.scrollHeight;
|
||||
};
|
||||
scroll();
|
||||
requestAnimationFrame(scroll);
|
||||
window.setTimeout(scroll, 80);
|
||||
window.setTimeout(scroll, 180);
|
||||
}
|
||||
|
||||
function messageAuthorName(message) {
|
||||
if (message?.author_name) return message.author_name;
|
||||
return message?.author_role === "user" ? t("wa_support_role_user") : "";
|
||||
}
|
||||
|
||||
afterUpdate(async () => {
|
||||
const nextKey = `${openedTicket?.ticket_id || ""}:${messages.length}:${messages.at(-1)?.message_id || ""}`;
|
||||
if (!messagesScrollEl || nextKey === lastMessageKey) return;
|
||||
lastMessageKey = nextKey;
|
||||
await tick();
|
||||
scrollMessagesToBottom();
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="content with-nav support-ticket-screen">
|
||||
{#if detailLoading && !openedTicket}
|
||||
<Card class="support-ticket-card">
|
||||
<header class="ticket-detail-header support-ticket-detail-header">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="support-back-button"
|
||||
onclick={() => supportStore.closeTicketView()}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>{t("wa_back")}</span>
|
||||
</Button>
|
||||
|
||||
<div class="ticket-detail-title">
|
||||
<small>{t("wa_loading")}</small>
|
||||
<h1>{t("wa_support_title")}</h1>
|
||||
</div>
|
||||
</header>
|
||||
</Card>
|
||||
|
||||
<Card class="support-conversation-card support-conversation-card--loading">
|
||||
<ScrollArea
|
||||
bind:element={messagesScrollEl}
|
||||
maxHeight="none"
|
||||
class="support-message-scroll scroll-area--mono"
|
||||
>
|
||||
<div class="ticket-message-list ticket-message-list--loading" aria-label={t("wa_loading")}>
|
||||
{#each Array(4) as _, index (index)}
|
||||
<div
|
||||
class:ticket-message-skeleton-row--outgoing={index % 2 === 1}
|
||||
class="ticket-message-skeleton-row"
|
||||
>
|
||||
<Skeleton variant="dot" width="32px" height="32px" />
|
||||
<span class="ticket-message-skeleton-content">
|
||||
<Skeleton variant="tiny" width={index % 2 === 1 ? "72px" : "96px"} />
|
||||
<Skeleton variant="block" height={index === 1 ? "72px" : "54px"} />
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
{:else if !openedTicket}
|
||||
<Card class="support-ticket-card support-ticket-state-card">
|
||||
<div class="empty-card">{t("wa_support_not_found")}</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card class="support-ticket-card">
|
||||
<header class="ticket-detail-header support-ticket-detail-header">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="support-back-button"
|
||||
onclick={() => supportStore.closeTicketView()}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>{t("wa_back")}</span>
|
||||
</Button>
|
||||
|
||||
<div class="ticket-detail-title">
|
||||
<small>{t("wa_support_ticket_number", { id: openedTicket.ticket_id })}</small>
|
||||
<h1>{openedTicket.subject}</h1>
|
||||
</div>
|
||||
|
||||
<div class="ticket-badges">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class={`ticket-status-badge ticket-status-badge--${openedTicket.status}`}
|
||||
>
|
||||
{t(`wa_support_status_${openedTicket.status}`)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="muted"
|
||||
class={`ticket-priority-badge ticket-priority-badge--${openedTicket.priority}`}
|
||||
>
|
||||
{t(`wa_support_priority_${openedTicket.priority}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
</Card>
|
||||
|
||||
<Card class="support-conversation-card">
|
||||
<ScrollArea
|
||||
bind:element={messagesScrollEl}
|
||||
maxHeight="none"
|
||||
class="support-message-scroll scroll-area--mono"
|
||||
>
|
||||
<div class="ticket-message-list">
|
||||
{#if messages.length}
|
||||
{#each messages as message}
|
||||
<TicketMessageBubble
|
||||
role={message.author_role}
|
||||
body={message.body}
|
||||
createdAt={message.created_at}
|
||||
isInternalNote={message.is_internal_note}
|
||||
supportBrand={brand}
|
||||
{userAvatarUrl}
|
||||
{userInitials}
|
||||
authorName={messageAuthorName(message)}
|
||||
{t}
|
||||
/>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="support-messages-empty">{t("wa_support_no_messages")}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<TicketComposer
|
||||
bind:value={reply}
|
||||
maxLength={maxBodyLength}
|
||||
disabled={closed}
|
||||
{sending}
|
||||
placeholder={closed ? t("wa_support_closed_hint") : t("wa_support_reply_placeholder")}
|
||||
sendLabel={t("wa_support_send")}
|
||||
onSend={send}
|
||||
/>
|
||||
</Card>
|
||||
{/if}
|
||||
</main>
|
||||
+111
-1
@@ -946,6 +946,7 @@
|
||||
"admin_settings_section_referral": "Referral program",
|
||||
"admin_settings_section_notifications": "Notifications",
|
||||
"admin_settings_section_devices": "Devices",
|
||||
"admin_settings_section_support": "Support",
|
||||
"admin_sync_started": "Synchronization started",
|
||||
"admin_sync_error": "Synchronization error",
|
||||
"admin_error": "Error",
|
||||
@@ -1385,6 +1386,22 @@
|
||||
"admin_settings_field_log_level_description": "Controls the 'Log Level' setting in admin overrides.",
|
||||
"admin_settings_field_log_chat_id_label": "Log Chat ID",
|
||||
"admin_settings_field_log_thread_id_label": "Log Thread ID",
|
||||
"admin_settings_field_log_support_thread_id_label": "Support thread ID",
|
||||
"admin_settings_field_log_support_thread_id_description": "Log chat thread for support ticket notifications.",
|
||||
"admin_settings_field_support_tickets_enabled_label": "Support tickets enabled",
|
||||
"admin_settings_field_support_tickets_enabled_description": "Enable the support tickets section in the user account and allow users to create tickets.",
|
||||
"admin_settings_field_support_admin_email_notifications_enabled_label": "Admin email notifications",
|
||||
"admin_settings_field_support_admin_email_notifications_enabled_description": "When disabled, new tickets and user replies are sent only to Telegram and the log chat.",
|
||||
"admin_settings_field_support_admin_notification_cooldown_seconds_label": "Telegram notification cooldown",
|
||||
"admin_settings_field_support_admin_notification_cooldown_seconds_description": "Minimum seconds between repeated Telegram/log notifications for the same unread ticket.",
|
||||
"admin_settings_field_support_admin_email_cooldown_seconds_label": "Email notification cooldown",
|
||||
"admin_settings_field_support_admin_email_cooldown_seconds_description": "Minimum seconds between repeated email notifications for the same unread ticket.",
|
||||
"admin_settings_field_support_ticket_max_body_length_label": "Max message length",
|
||||
"admin_settings_field_support_ticket_max_body_length_description": "Maximum number of characters in a ticket message.",
|
||||
"admin_settings_field_support_ticket_max_subject_length_label": "Max subject length",
|
||||
"admin_settings_field_support_ticket_max_subject_length_description": "Maximum number of characters in a ticket subject.",
|
||||
"admin_settings_field_support_ticket_rate_limit_per_hour_label": "Ticket limit per hour",
|
||||
"admin_settings_field_support_ticket_rate_limit_per_hour_description": "How many new tickets a user can create per hour. 0 means unlimited.",
|
||||
"admin_settings_field_my_devices_section_enabled_label": "My Devices Section Enabled",
|
||||
"admin_settings_field_user_hwid_device_limit_label": "User HWID Device Limit",
|
||||
"admin_settings_field_user_traffic_limit_gb_label": "User Traffic Limit Gb",
|
||||
@@ -1410,5 +1427,98 @@
|
||||
"wa_password_mismatch": "Passwords do not match",
|
||||
"wa_password_code_send_failed": "Could not send the code",
|
||||
"wa_password_set_failed": "Could not save password",
|
||||
"wa_password_set_success": "Password saved"
|
||||
"wa_password_set_success": "Password saved",
|
||||
"wa_nav_support": "Support",
|
||||
"wa_support_title": "Support",
|
||||
"wa_support_subtitle": "Tickets and replies from the team",
|
||||
"wa_support_new_ticket": "New request",
|
||||
"wa_support_contact_support": "Contact support",
|
||||
"wa_support_subject": "Subject",
|
||||
"wa_support_subject_placeholder": "Briefly describe the issue",
|
||||
"wa_support_category": "Category",
|
||||
"wa_support_priority": "Priority",
|
||||
"wa_support_message": "Message",
|
||||
"wa_support_message_placeholder": "Tell us what happened. More details help us respond faster.",
|
||||
"wa_support_create": "Create ticket",
|
||||
"wa_support_creating": "Creating...",
|
||||
"wa_support_empty": "No tickets yet",
|
||||
"wa_support_no_open_tickets": "No open requests",
|
||||
"wa_support_empty_hint": "Create a new request if you need help with billing, connection, or your account.",
|
||||
"wa_support_not_found": "Ticket not found",
|
||||
"wa_support_send": "Send",
|
||||
"wa_support_reply_placeholder": "Write a reply",
|
||||
"wa_support_closed_hint": "This ticket is closed",
|
||||
"wa_support_create_failed": "Could not create ticket",
|
||||
"wa_support_send_failed": "Could not send reply",
|
||||
"wa_support_filter_all": "All",
|
||||
"wa_support_filter_active": "Active",
|
||||
"wa_support_filter_label": "Ticket filter",
|
||||
"wa_support_ticket_number": "Ticket #{id}",
|
||||
"wa_support_no_messages": "No messages yet",
|
||||
"wa_support_role_user": "You",
|
||||
"wa_support_role_admin": "Support",
|
||||
"wa_support_role_system": "System",
|
||||
"wa_support_internal_note": "Internal note",
|
||||
"wa_support_open_ticket": "Open ticket",
|
||||
"wa_support_open_ticket_hint": "Open the ticket in Mini App.",
|
||||
"wa_support_category_billing": "Billing",
|
||||
"wa_support_category_technical": "Technical",
|
||||
"wa_support_category_account": "Account",
|
||||
"wa_support_category_other": "Other",
|
||||
"wa_support_priority_low": "Low",
|
||||
"wa_support_priority_normal": "Normal",
|
||||
"wa_support_priority_high": "High",
|
||||
"wa_support_priority_urgent": "Urgent",
|
||||
"wa_support_status_open": "Open",
|
||||
"wa_support_status_awaiting_user": "Awaiting user",
|
||||
"wa_support_status_awaiting_admin": "Awaiting admin",
|
||||
"wa_support_status_resolved": "Resolved",
|
||||
"wa_support_status_closed": "Closed",
|
||||
"admin_nav_support": "Support",
|
||||
"admin_section_support_title": "Support",
|
||||
"admin_section_support_subtitle": "Ticket inbox and user replies",
|
||||
"admin_support_search": "Search",
|
||||
"admin_support_empty": "No tickets yet",
|
||||
"admin_support_select_ticket": "Select a ticket",
|
||||
"admin_support_close_ticket": "Close",
|
||||
"admin_support_internal_note": "Internal note",
|
||||
"admin_support_reply_placeholder": "Reply",
|
||||
"admin_support_no_messages": "No messages yet",
|
||||
"admin_support_filter_all": "All",
|
||||
"admin_support_filter_active": "Active",
|
||||
"admin_support_filter_closed": "Closed",
|
||||
"admin_support_filter_all_priorities": "Any priority",
|
||||
"admin_support_filter_all_categories": "All categories",
|
||||
"admin_support_ticket_number": "Ticket #{id}",
|
||||
"admin_support_priority": "Priority",
|
||||
"admin_support_category": "Category",
|
||||
"admin_support_role_user": "User",
|
||||
"admin_support_role_admin": "Admin",
|
||||
"admin_support_role_system": "System",
|
||||
"admin_support_user_context": "User",
|
||||
"admin_support_open_user": "User card",
|
||||
"admin_support_tariff": "Tariff",
|
||||
"admin_support_status": "Status",
|
||||
"admin_support_remaining": "Remaining",
|
||||
"admin_support_unread": "Unread",
|
||||
"admin_support_summary": "Support summary",
|
||||
"admin_support_ticket_dialog": "Support conversation",
|
||||
"admin_support_status_open": "Open",
|
||||
"admin_support_status_awaiting_user": "Awaiting user",
|
||||
"admin_support_status_awaiting_admin": "Awaiting admin",
|
||||
"admin_support_status_resolved": "Resolved",
|
||||
"admin_support_status_closed": "Closed",
|
||||
"admin_support_priority_low": "Low",
|
||||
"admin_support_priority_normal": "Normal",
|
||||
"admin_support_priority_high": "High",
|
||||
"admin_support_priority_urgent": "Urgent",
|
||||
"admin_support_category_billing": "Billing",
|
||||
"admin_support_category_technical": "Technical",
|
||||
"admin_support_category_account": "Account",
|
||||
"admin_support_category_other": "Other",
|
||||
"admin_support_sort_importance_desc": "Most important",
|
||||
"admin_sort_updated_desc": "Newest activity",
|
||||
"admin_sort_updated_asc": "Oldest activity",
|
||||
"admin_sort_created_desc": "Newest created",
|
||||
"admin_sort_created_asc": "Oldest created"
|
||||
}
|
||||
|
||||
+111
-1
@@ -946,6 +946,7 @@
|
||||
"admin_settings_section_referral": "Реферальная программа",
|
||||
"admin_settings_section_notifications": "Уведомления",
|
||||
"admin_settings_section_devices": "Устройства",
|
||||
"admin_settings_section_support": "Поддержка",
|
||||
"admin_sync_started": "Синхронизация запущена",
|
||||
"admin_sync_error": "Ошибка синхронизации",
|
||||
"admin_error": "Ошибка",
|
||||
@@ -1385,6 +1386,22 @@
|
||||
"admin_settings_field_log_level_description": "DEBUG / INFO / WARNING / ERROR",
|
||||
"admin_settings_field_log_chat_id_label": "ID чата для логов",
|
||||
"admin_settings_field_log_thread_id_label": "ID треда (для супергрупп)",
|
||||
"admin_settings_field_log_support_thread_id_label": "ID треда поддержки",
|
||||
"admin_settings_field_log_support_thread_id_description": "Тред лог-чата для уведомлений о тикетах поддержки.",
|
||||
"admin_settings_field_support_tickets_enabled_label": "Тикеты поддержки включены",
|
||||
"admin_settings_field_support_tickets_enabled_description": "Показывает раздел поддержки в ЛК и включает создание тикетов.",
|
||||
"admin_settings_field_support_admin_email_notifications_enabled_label": "Email-уведомления админам",
|
||||
"admin_settings_field_support_admin_email_notifications_enabled_description": "Если выключено, новые тикеты и ответы пользователей останутся только в Telegram и лог-чате.",
|
||||
"admin_settings_field_support_admin_notification_cooldown_seconds_label": "Пауза Telegram-уведомлений",
|
||||
"admin_settings_field_support_admin_notification_cooldown_seconds_description": "Минимум секунд между повторными Telegram/log уведомлениями по одному непрочитанному тикету.",
|
||||
"admin_settings_field_support_admin_email_cooldown_seconds_label": "Пауза email-уведомлений",
|
||||
"admin_settings_field_support_admin_email_cooldown_seconds_description": "Минимум секунд между повторными email-уведомлениями по одному непрочитанному тикету.",
|
||||
"admin_settings_field_support_ticket_max_body_length_label": "Макс. длина сообщения",
|
||||
"admin_settings_field_support_ticket_max_body_length_description": "Максимальное количество символов в сообщении тикета.",
|
||||
"admin_settings_field_support_ticket_max_subject_length_label": "Макс. длина темы",
|
||||
"admin_settings_field_support_ticket_max_subject_length_description": "Максимальное количество символов в теме тикета.",
|
||||
"admin_settings_field_support_ticket_rate_limit_per_hour_label": "Лимит тикетов в час",
|
||||
"admin_settings_field_support_ticket_rate_limit_per_hour_description": "Сколько новых тикетов пользователь может создать за час. 0 — без лимита.",
|
||||
"admin_settings_field_my_devices_section_enabled_label": "Раздел «Мои устройства»",
|
||||
"admin_settings_field_user_hwid_device_limit_label": "Лимит устройств по умолчанию (0 = ∞)",
|
||||
"admin_settings_field_user_traffic_limit_gb_label": "Лимит трафика пользователя (ГБ)",
|
||||
@@ -1410,5 +1427,98 @@
|
||||
"wa_password_mismatch": "Пароли не совпадают",
|
||||
"wa_password_code_send_failed": "Не удалось отправить код",
|
||||
"wa_password_set_failed": "Не удалось сохранить пароль",
|
||||
"wa_password_set_success": "Пароль сохранён"
|
||||
"wa_password_set_success": "Пароль сохранён",
|
||||
"wa_nav_support": "Поддержка",
|
||||
"wa_support_title": "Поддержка",
|
||||
"wa_support_subtitle": "Тикеты и ответы команды",
|
||||
"wa_support_new_ticket": "Новое обращение",
|
||||
"wa_support_contact_support": "Связаться с поддержкой",
|
||||
"wa_support_subject": "Тема",
|
||||
"wa_support_subject_placeholder": "Кратко опишите вопрос",
|
||||
"wa_support_category": "Категория",
|
||||
"wa_support_priority": "Приоритет",
|
||||
"wa_support_message": "Сообщение",
|
||||
"wa_support_message_placeholder": "Расскажите, что произошло. Чем больше деталей, тем быстрее поможем.",
|
||||
"wa_support_create": "Создать тикет",
|
||||
"wa_support_creating": "Создаём...",
|
||||
"wa_support_empty": "Тикетов пока нет",
|
||||
"wa_support_no_open_tickets": "Нет открытых обращений",
|
||||
"wa_support_empty_hint": "Создайте новое обращение, если нужна помощь с оплатой, подключением или аккаунтом.",
|
||||
"wa_support_not_found": "Тикет не найден",
|
||||
"wa_support_send": "Отправить",
|
||||
"wa_support_reply_placeholder": "Напишите ответ",
|
||||
"wa_support_closed_hint": "Тикет закрыт",
|
||||
"wa_support_create_failed": "Не удалось создать тикет",
|
||||
"wa_support_send_failed": "Не удалось отправить ответ",
|
||||
"wa_support_filter_all": "Все",
|
||||
"wa_support_filter_active": "Активные",
|
||||
"wa_support_filter_label": "Фильтр обращений",
|
||||
"wa_support_ticket_number": "Тикет #{id}",
|
||||
"wa_support_no_messages": "Сообщений пока нет",
|
||||
"wa_support_role_user": "Вы",
|
||||
"wa_support_role_admin": "Поддержка",
|
||||
"wa_support_role_system": "Система",
|
||||
"wa_support_internal_note": "Внутренняя заметка",
|
||||
"wa_support_open_ticket": "Открыть тикет",
|
||||
"wa_support_open_ticket_hint": "Откройте тикет в Mini App.",
|
||||
"wa_support_category_billing": "Оплата",
|
||||
"wa_support_category_technical": "Техническое",
|
||||
"wa_support_category_account": "Аккаунт",
|
||||
"wa_support_category_other": "Другое",
|
||||
"wa_support_priority_low": "Низкий",
|
||||
"wa_support_priority_normal": "Обычный",
|
||||
"wa_support_priority_high": "Высокий",
|
||||
"wa_support_priority_urgent": "Срочный",
|
||||
"wa_support_status_open": "Открыт",
|
||||
"wa_support_status_awaiting_user": "Ждёт пользователя",
|
||||
"wa_support_status_awaiting_admin": "Ждёт админа",
|
||||
"wa_support_status_resolved": "Решён",
|
||||
"wa_support_status_closed": "Закрыт",
|
||||
"admin_nav_support": "Поддержка",
|
||||
"admin_section_support_title": "Поддержка",
|
||||
"admin_section_support_subtitle": "Инбокс тикетов и ответы пользователям",
|
||||
"admin_support_search": "Поиск",
|
||||
"admin_support_empty": "Тикетов пока нет",
|
||||
"admin_support_select_ticket": "Выберите тикет",
|
||||
"admin_support_close_ticket": "Закрыть",
|
||||
"admin_support_internal_note": "Внутренняя заметка",
|
||||
"admin_support_reply_placeholder": "Ответ",
|
||||
"admin_support_no_messages": "Сообщений пока нет",
|
||||
"admin_support_filter_all": "Все",
|
||||
"admin_support_filter_active": "Активные",
|
||||
"admin_support_filter_closed": "Закрытые",
|
||||
"admin_support_filter_all_priorities": "Любой приоритет",
|
||||
"admin_support_filter_all_categories": "Все категории",
|
||||
"admin_support_ticket_number": "Тикет #{id}",
|
||||
"admin_support_priority": "Приоритет",
|
||||
"admin_support_category": "Категория",
|
||||
"admin_support_role_user": "Пользователь",
|
||||
"admin_support_role_admin": "Админ",
|
||||
"admin_support_role_system": "Система",
|
||||
"admin_support_user_context": "Пользователь",
|
||||
"admin_support_open_user": "Карточка",
|
||||
"admin_support_tariff": "Тариф",
|
||||
"admin_support_status": "Статус",
|
||||
"admin_support_remaining": "Осталось",
|
||||
"admin_support_unread": "Непрочитано",
|
||||
"admin_support_summary": "Сводка поддержки",
|
||||
"admin_support_ticket_dialog": "Диалог поддержки",
|
||||
"admin_support_status_open": "Открыт",
|
||||
"admin_support_status_awaiting_user": "Ждёт пользователя",
|
||||
"admin_support_status_awaiting_admin": "Ждёт админа",
|
||||
"admin_support_status_resolved": "Решён",
|
||||
"admin_support_status_closed": "Закрыт",
|
||||
"admin_support_priority_low": "Низкий",
|
||||
"admin_support_priority_normal": "Обычный",
|
||||
"admin_support_priority_high": "Высокий",
|
||||
"admin_support_priority_urgent": "Срочный",
|
||||
"admin_support_category_billing": "Оплата",
|
||||
"admin_support_category_technical": "Техническое",
|
||||
"admin_support_category_account": "Аккаунт",
|
||||
"admin_support_category_other": "Другое",
|
||||
"admin_support_sort_importance_desc": "Важные сверху",
|
||||
"admin_sort_updated_desc": "Сначала новые",
|
||||
"admin_sort_updated_asc": "Сначала старые",
|
||||
"admin_sort_created_desc": "Созданы недавно",
|
||||
"admin_sort_created_asc": "Созданы давно"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from bot.app.web.admin_settings_manifest import manifest_payload
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
SUPPORT_RELATED_SETTINGS = (
|
||||
"LOG_SUPPORT_THREAD_ID",
|
||||
"SUPPORT_TICKETS_ENABLED",
|
||||
"SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED",
|
||||
"SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS",
|
||||
"SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS",
|
||||
"SUPPORT_TICKET_MAX_BODY_LENGTH",
|
||||
"SUPPORT_TICKET_MAX_SUBJECT_LENGTH",
|
||||
"SUPPORT_TICKET_RATE_LIMIT_PER_HOUR",
|
||||
)
|
||||
|
||||
|
||||
def _manifest_by_key() -> dict[str, dict]:
|
||||
return {item["key"]: item for item in manifest_payload()}
|
||||
|
||||
|
||||
def _locale(language: str) -> dict[str, str]:
|
||||
return json.loads((REPO_ROOT / "locales" / f"{language}.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_support_settings_manifest_uses_admin_i18n_keys():
|
||||
manifest = _manifest_by_key()
|
||||
|
||||
assert manifest["SUPPORT_TICKETS_ENABLED"]["section"] == "support"
|
||||
assert manifest["SUPPORT_TICKETS_ENABLED"]["section_order"] == 8
|
||||
|
||||
for setting_key in SUPPORT_RELATED_SETTINGS:
|
||||
field = manifest[setting_key]
|
||||
prefix = f"admin_settings_field_{setting_key.lower()}"
|
||||
|
||||
assert field["i18n_label_key"] == f"{prefix}_label"
|
||||
assert field["i18n_description_key"] == f"{prefix}_description"
|
||||
|
||||
|
||||
def test_support_settings_i18n_keys_exist_in_admin_locales():
|
||||
manifest = _manifest_by_key()
|
||||
|
||||
for language in ("ru", "en"):
|
||||
messages = _locale(language)
|
||||
|
||||
assert "admin_settings_section_support" in messages
|
||||
for setting_key in SUPPORT_RELATED_SETTINGS:
|
||||
field = manifest[setting_key]
|
||||
assert field["i18n_label_key"] in messages
|
||||
assert field["i18n_description_key"] in messages
|
||||
@@ -18,7 +18,7 @@ import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from bot.app.factories.build_services import build_core_services
|
||||
from bot.payment_providers.yookassa import YooKassaService
|
||||
@@ -26,7 +26,6 @@ from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
# Strip all provider env so per-provider BaseSettings models don't pick up
|
||||
# real credentials from the local .env file during tests.
|
||||
_PROVIDER_ENV_PREFIXES = (
|
||||
@@ -140,6 +139,9 @@ class BuildServicesWiringTests(unittest.TestCase):
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"promo_code_service",
|
||||
"notification_service",
|
||||
"email_auth_service",
|
||||
"support_service",
|
||||
"stars_service",
|
||||
"cryptopay_service",
|
||||
"freekassa_service",
|
||||
|
||||
@@ -152,11 +152,22 @@ class SettingsTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(settings.TRIAL_TRAFFIC_STRATEGY, "WEEK")
|
||||
|
||||
def test_support_admin_email_notifications_default_to_disabled(self):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
POSTGRES_USER="app_user",
|
||||
POSTGRES_PASSWORD="app_password",
|
||||
)
|
||||
|
||||
self.assertFalse(settings.SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED)
|
||||
|
||||
def test_payment_button_presentation_env_values_are_available(self):
|
||||
"""Presentation overrides now live on each provider's BaseSettings
|
||||
model instead of the central Settings — verify they're loaded from
|
||||
env and exposed via the provider bundle."""
|
||||
import os
|
||||
|
||||
from bot.payment_providers import build_provider_configs, get_spec_presentation
|
||||
|
||||
os.environ["PAYMENT_YOOKASSA_WEBAPP_LABEL_RU"] = "Карта"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from bot.app.web.admin_api_impl.support import AdminTicketPatchPayload, AdminTicketReplyPayload
|
||||
|
||||
|
||||
def test_admin_patch_payload_accepts_closed_status_and_urgent_priority():
|
||||
payload = AdminTicketPatchPayload.model_validate(
|
||||
{"status": "closed", "priority": "urgent", "category": "billing"}
|
||||
)
|
||||
|
||||
assert payload.status == "closed"
|
||||
assert payload.priority == "urgent"
|
||||
|
||||
|
||||
def test_admin_reply_payload_supports_internal_note():
|
||||
payload = AdminTicketReplyPayload.model_validate({"body": " note ", "is_internal_note": True})
|
||||
|
||||
assert payload.body == "note"
|
||||
assert payload.is_internal_note is True
|
||||
@@ -0,0 +1,25 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from bot.app.web.webapp.payloads import CreateTicketPayload, TicketReplyPayload
|
||||
|
||||
|
||||
def test_user_ticket_payload_accepts_only_public_priorities():
|
||||
payload = CreateTicketPayload.model_validate(
|
||||
{"subject": "Help", "category": "technical", "priority": "high", "body": "Text"}
|
||||
)
|
||||
|
||||
assert payload.priority == "high"
|
||||
|
||||
|
||||
def test_user_ticket_payload_rejects_admin_only_priority():
|
||||
with pytest.raises(ValidationError):
|
||||
CreateTicketPayload.model_validate(
|
||||
{"subject": "Help", "category": "technical", "priority": "urgent", "body": "Text"}
|
||||
)
|
||||
|
||||
|
||||
def test_ticket_reply_trims_body():
|
||||
payload = TicketReplyPayload.model_validate({"body": " hello "})
|
||||
|
||||
assert payload.body == "hello"
|
||||
@@ -0,0 +1,12 @@
|
||||
from db.dal import support_dal
|
||||
|
||||
|
||||
def test_support_dal_status_groups_are_future_close_ready():
|
||||
assert "closed" in support_dal.CLOSED_STATUSES
|
||||
assert "resolved" in support_dal.CLOSED_STATUSES
|
||||
assert "awaiting_admin" in support_dal.ACTIVE_STATUSES
|
||||
|
||||
|
||||
def test_support_dal_all_status_filter_means_no_filter():
|
||||
assert support_dal._status_condition("all") is None
|
||||
assert support_dal._status_condition("any") is None
|
||||
@@ -0,0 +1,23 @@
|
||||
from db.migrator import MIGRATIONS
|
||||
from db.models import SupportTicket, SupportTicketMessage
|
||||
|
||||
|
||||
def test_support_migration_is_registered_after_existing_revisions():
|
||||
ids = [migration.id for migration in MIGRATIONS]
|
||||
|
||||
assert "0024_add_support_tickets" in ids
|
||||
assert ids.index("0024_add_support_tickets") > ids.index("0023_add_email_password_auth_fields")
|
||||
assert "0025_add_support_notification_timestamps" in ids
|
||||
assert ids.index("0025_add_support_notification_timestamps") > ids.index(
|
||||
"0024_add_support_tickets"
|
||||
)
|
||||
|
||||
|
||||
def test_support_models_expose_expected_tables():
|
||||
assert SupportTicket.__tablename__ == "support_tickets"
|
||||
assert SupportTicketMessage.__tablename__ == "support_ticket_messages"
|
||||
assert "admin_last_notified_at" in SupportTicket.__table__.columns
|
||||
assert "admin_last_emailed_at" in SupportTicket.__table__.columns
|
||||
assert "ix_support_tickets_status_last_msg" in {
|
||||
index.name for index in SupportTicket.__table__.indexes
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from bot.services.notification_service import NotificationService
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
def _settings(**overrides):
|
||||
data = {
|
||||
"BOT_TOKEN": "123456:test",
|
||||
"POSTGRES_USER": "app_user",
|
||||
"POSTGRES_PASSWORD": "app_password",
|
||||
}
|
||||
data.update(overrides)
|
||||
return Settings(_env_file=None, **data)
|
||||
|
||||
|
||||
def test_support_ticket_url_uses_subscription_mini_app_url():
|
||||
service = NotificationService(
|
||||
bot=SimpleNamespace(),
|
||||
settings=_settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.com"),
|
||||
)
|
||||
|
||||
assert service._support_ticket_url(42, admin=True) == "https://app.example.com/admin/support/42"
|
||||
assert service._support_ticket_url(42, admin=False) == "https://app.example.com/support/42"
|
||||
|
||||
|
||||
def test_support_ticket_url_falls_back_to_startapp_deeplink():
|
||||
service = NotificationService(
|
||||
bot=SimpleNamespace(),
|
||||
settings=_settings(),
|
||||
bot_username="demo_bot",
|
||||
)
|
||||
|
||||
assert service._support_ticket_url(42) == "https://t.me/demo_bot?startapp=ticket_42"
|
||||
|
||||
|
||||
def test_admin_support_keyboard_uses_consistent_admin_links():
|
||||
service = NotificationService(
|
||||
bot=SimpleNamespace(),
|
||||
settings=_settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.com/app"),
|
||||
)
|
||||
ticket = SimpleNamespace(ticket_id=42)
|
||||
user = SimpleNamespace(user_id=100200300)
|
||||
|
||||
keyboard = service._support_keyboard(ticket, user, admin=True)
|
||||
ticket_button = keyboard.inline_keyboard[0][0]
|
||||
user_card_button = keyboard.inline_keyboard[1][1]
|
||||
|
||||
assert keyboard.inline_keyboard[0][0].text == "Открыть тикет"
|
||||
assert ticket_button.url is None
|
||||
assert ticket_button.web_app.url == "https://app.example.com/app/admin/support/42"
|
||||
assert keyboard.inline_keyboard[1][0].url == "tg://user?id=100200300"
|
||||
assert user_card_button.url is None
|
||||
assert user_card_button.web_app.url == "https://app.example.com/app/admin/users/100200300"
|
||||
|
||||
|
||||
def test_admin_support_keyboard_falls_back_to_startapp_url():
|
||||
service = NotificationService(
|
||||
bot=SimpleNamespace(),
|
||||
settings=_settings(),
|
||||
bot_username="demo_bot",
|
||||
)
|
||||
ticket = SimpleNamespace(ticket_id=42)
|
||||
user = SimpleNamespace(user_id=100200300)
|
||||
|
||||
keyboard = service._support_keyboard(ticket, user, admin=True)
|
||||
button = keyboard.inline_keyboard[0][0]
|
||||
|
||||
assert button.web_app is None
|
||||
assert button.url == "https://t.me/demo_bot?startapp=ticket_42"
|
||||
|
||||
|
||||
def test_user_support_keyboard_uses_web_app_button_when_configured():
|
||||
service = NotificationService(
|
||||
bot=SimpleNamespace(),
|
||||
settings=_settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.com/app"),
|
||||
)
|
||||
ticket = SimpleNamespace(ticket_id=42)
|
||||
user = SimpleNamespace(language_code="ru")
|
||||
|
||||
keyboard = service._support_user_keyboard(ticket, user)
|
||||
button = keyboard.inline_keyboard[0][0]
|
||||
|
||||
assert button.text == "Открыть тикет"
|
||||
assert button.url is None
|
||||
assert button.web_app.url == "https://app.example.com/app/support/42"
|
||||
|
||||
|
||||
def test_user_support_keyboard_falls_back_to_startapp_url():
|
||||
service = NotificationService(
|
||||
bot=SimpleNamespace(),
|
||||
settings=_settings(),
|
||||
bot_username="demo_bot",
|
||||
)
|
||||
ticket = SimpleNamespace(ticket_id=42)
|
||||
user = SimpleNamespace(language_code="ru")
|
||||
|
||||
keyboard = service._support_user_keyboard(ticket, user)
|
||||
button = keyboard.inline_keyboard[0][0]
|
||||
|
||||
assert button.text == "Открыть тикет"
|
||||
assert button.web_app is None
|
||||
assert button.url == "https://t.me/demo_bot?startapp=ticket_42"
|
||||
|
||||
|
||||
def test_admin_support_email_notifications_can_be_disabled():
|
||||
sent = []
|
||||
|
||||
class EmailService:
|
||||
async def send_rendered_email(self, *, email, content):
|
||||
sent.append((email, content))
|
||||
|
||||
service = NotificationService(
|
||||
bot=SimpleNamespace(),
|
||||
settings=_settings(SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=False),
|
||||
email_auth_service=EmailService(),
|
||||
)
|
||||
|
||||
async def admin_email_users():
|
||||
return [SimpleNamespace(user_id=1, email="admin@example.com", language_code="en")]
|
||||
|
||||
service._admin_email_users = admin_email_users
|
||||
|
||||
async def run():
|
||||
await service._send_admin_support_email(
|
||||
lambda *_args, **_kwargs: SimpleNamespace(subject="Ticket", html="Body", text="Body"),
|
||||
ticket_id=1,
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_admin_support_email_notifications_default_to_disabled():
|
||||
sent = []
|
||||
|
||||
class EmailService:
|
||||
async def send_rendered_email(self, *, email, content):
|
||||
sent.append((email, content))
|
||||
|
||||
service = NotificationService(
|
||||
bot=SimpleNamespace(),
|
||||
settings=_settings(),
|
||||
email_auth_service=EmailService(),
|
||||
)
|
||||
|
||||
async def admin_email_users():
|
||||
return [SimpleNamespace(user_id=1, email="admin@example.com", language_code="en")]
|
||||
|
||||
service._admin_email_users = admin_email_users
|
||||
|
||||
async def run():
|
||||
await service._send_admin_support_email(
|
||||
lambda *_args, **_kwargs: SimpleNamespace(subject="Ticket", html="Body", text="Body"),
|
||||
ticket_id=1,
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_persisted_support_email_override_disables_env_enabled(monkeypatch):
|
||||
sent = []
|
||||
|
||||
class EmailService:
|
||||
async def send_rendered_email(self, *, email, content):
|
||||
sent.append((email, content))
|
||||
|
||||
class SessionFactory:
|
||||
def __call__(self):
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return SimpleNamespace()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
async def get_override_value(_session, key):
|
||||
assert key == "SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED"
|
||||
return True, False
|
||||
|
||||
monkeypatch.setattr(
|
||||
"bot.services.notification_service.app_settings_dal.get_override_value",
|
||||
get_override_value,
|
||||
)
|
||||
|
||||
service = NotificationService(
|
||||
bot=SimpleNamespace(),
|
||||
settings=_settings(SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=True),
|
||||
session_factory=SessionFactory(),
|
||||
email_auth_service=EmailService(),
|
||||
)
|
||||
|
||||
async def admin_email_users():
|
||||
return [SimpleNamespace(user_id=1, email="admin@example.com", language_code="en")]
|
||||
|
||||
service._admin_email_users = admin_email_users
|
||||
|
||||
async def run():
|
||||
await service._send_admin_support_email(
|
||||
lambda *_args, **_kwargs: SimpleNamespace(subject="Ticket", html="Body", text="Body"),
|
||||
ticket_id=1,
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_persisted_support_email_override_enables_default_disabled(monkeypatch):
|
||||
sent = []
|
||||
|
||||
class EmailService:
|
||||
async def send_rendered_email(self, *, email, content):
|
||||
sent.append((email, content))
|
||||
|
||||
class SessionFactory:
|
||||
def __call__(self):
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return SimpleNamespace()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
async def get_override_value(_session, key):
|
||||
assert key == "SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED"
|
||||
return True, True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"bot.services.notification_service.app_settings_dal.get_override_value",
|
||||
get_override_value,
|
||||
)
|
||||
|
||||
service = NotificationService(
|
||||
bot=SimpleNamespace(),
|
||||
settings=_settings(),
|
||||
session_factory=SessionFactory(),
|
||||
email_auth_service=EmailService(),
|
||||
)
|
||||
|
||||
async def admin_email_users():
|
||||
return [SimpleNamespace(user_id=1, email="admin@example.com", language_code="en")]
|
||||
|
||||
service._admin_email_users = admin_email_users
|
||||
|
||||
async def run():
|
||||
await service._send_admin_support_email(
|
||||
lambda *_args, **_kwargs: SimpleNamespace(subject="Ticket", html="Body", text="Body"),
|
||||
ticket_id=1,
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
assert len(sent) == 1
|
||||
|
||||
|
||||
def test_disabled_admin_support_email_keeps_telegram_and_log_notifications():
|
||||
emails = []
|
||||
channels = []
|
||||
|
||||
class EmailService:
|
||||
async def send_rendered_email(self, *, email, content):
|
||||
emails.append((email, content))
|
||||
|
||||
service = NotificationService(
|
||||
bot=SimpleNamespace(),
|
||||
settings=_settings(
|
||||
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=False,
|
||||
SUBSCRIPTION_MINI_APP_URL="https://app.example.com",
|
||||
),
|
||||
email_auth_service=EmailService(),
|
||||
)
|
||||
|
||||
async def send_to_admins(message, reply_markup=None):
|
||||
channels.append(("admins", bool(message), bool(reply_markup)))
|
||||
|
||||
async def send_to_log_channel(message, thread_id=None, reply_markup=None):
|
||||
channels.append(("log", bool(message), bool(reply_markup)))
|
||||
|
||||
service._send_to_admins = send_to_admins
|
||||
service._send_to_log_channel = send_to_log_channel
|
||||
|
||||
ticket = SimpleNamespace(
|
||||
ticket_id=7,
|
||||
priority="normal",
|
||||
category="technical",
|
||||
subject="Connection issue",
|
||||
)
|
||||
user = SimpleNamespace(
|
||||
user_id=100200300,
|
||||
username="user",
|
||||
first_name="User",
|
||||
last_name=None,
|
||||
email="user@example.com",
|
||||
)
|
||||
|
||||
asyncio.run(
|
||||
service.notify_new_support_ticket(
|
||||
ticket,
|
||||
user,
|
||||
"Cannot connect",
|
||||
{"tariff": "Standard", "end_date": "2026-06-01"},
|
||||
)
|
||||
)
|
||||
|
||||
assert [item[0] for item in channels] == ["admins", "log"]
|
||||
assert emails == []
|
||||
|
||||
|
||||
def test_support_user_reply_can_send_email_without_telegram_channels():
|
||||
emails = []
|
||||
channels = []
|
||||
|
||||
service = NotificationService(
|
||||
bot=SimpleNamespace(),
|
||||
settings=_settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.com"),
|
||||
)
|
||||
|
||||
async def send_to_admins(message, reply_markup=None):
|
||||
channels.append(("admins", bool(message), bool(reply_markup)))
|
||||
|
||||
async def send_to_log_channel(message, thread_id=None, reply_markup=None):
|
||||
channels.append(("log", bool(message), bool(reply_markup)))
|
||||
|
||||
async def send_admin_support_email(renderer, **kwargs):
|
||||
emails.append(kwargs)
|
||||
|
||||
service._send_to_admins = send_to_admins
|
||||
service._send_to_log_channel = send_to_log_channel
|
||||
service._send_admin_support_email = send_admin_support_email
|
||||
|
||||
ticket = SimpleNamespace(
|
||||
ticket_id=7,
|
||||
priority="normal",
|
||||
category="technical",
|
||||
subject="Connection issue",
|
||||
)
|
||||
message = SimpleNamespace(body="Still cannot connect")
|
||||
user = SimpleNamespace(
|
||||
user_id=100200300,
|
||||
username="user",
|
||||
first_name="User",
|
||||
last_name=None,
|
||||
email="user@example.com",
|
||||
)
|
||||
|
||||
asyncio.run(
|
||||
service.notify_support_user_reply(
|
||||
ticket,
|
||||
message,
|
||||
user,
|
||||
{},
|
||||
unread_count=3,
|
||||
send_telegram=False,
|
||||
send_email=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert channels == []
|
||||
assert emails[0]["ticket_id"] == 7
|
||||
@@ -0,0 +1,102 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from bot.services.support_service import (
|
||||
SupportService,
|
||||
TicketForbidden,
|
||||
_support_admin_notification_decision,
|
||||
)
|
||||
|
||||
|
||||
def test_support_traffic_snapshot_calculates_percent_and_left_bytes():
|
||||
snapshot = SupportService._traffic_snapshot(25, 100)
|
||||
|
||||
assert snapshot["percent"] == 25
|
||||
assert snapshot["left_bytes"] == 75
|
||||
|
||||
|
||||
def test_ticket_forbidden_error_code_is_stable():
|
||||
exc = TicketForbidden("ticket_forbidden")
|
||||
|
||||
assert str(exc) == "ticket_forbidden"
|
||||
|
||||
|
||||
def test_regular_limit_treats_unlimited_override_as_zero_limit():
|
||||
sub = SimpleNamespace(regular_unlimited_override=True, traffic_limit_bytes=100)
|
||||
|
||||
assert SupportService._regular_limit(sub) == 0
|
||||
|
||||
|
||||
def test_support_admin_notification_decision_sends_first_unread():
|
||||
now = datetime(2026, 5, 20, tzinfo=timezone.utc)
|
||||
ticket = SimpleNamespace(
|
||||
unread_admin_count=1,
|
||||
admin_last_notified_at=now,
|
||||
admin_last_emailed_at=now,
|
||||
)
|
||||
settings = SimpleNamespace(
|
||||
SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS=300,
|
||||
SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS=1800,
|
||||
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=True,
|
||||
)
|
||||
|
||||
decision = _support_admin_notification_decision(ticket, settings, now=now)
|
||||
|
||||
assert decision.send_telegram is True
|
||||
assert decision.send_email is True
|
||||
|
||||
|
||||
def test_support_admin_notification_decision_defaults_email_disabled():
|
||||
now = datetime(2026, 5, 20, tzinfo=timezone.utc)
|
||||
ticket = SimpleNamespace(
|
||||
unread_admin_count=1,
|
||||
admin_last_notified_at=None,
|
||||
admin_last_emailed_at=None,
|
||||
)
|
||||
settings = SimpleNamespace(
|
||||
SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS=300,
|
||||
SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS=1800,
|
||||
)
|
||||
|
||||
decision = _support_admin_notification_decision(ticket, settings, now=now)
|
||||
|
||||
assert decision.send_telegram is True
|
||||
assert decision.send_email is False
|
||||
|
||||
|
||||
def test_support_admin_notification_decision_suppresses_fast_followups():
|
||||
now = datetime(2026, 5, 20, tzinfo=timezone.utc)
|
||||
ticket = SimpleNamespace(
|
||||
unread_admin_count=4,
|
||||
admin_last_notified_at=now - timedelta(seconds=60),
|
||||
admin_last_emailed_at=now - timedelta(seconds=60),
|
||||
)
|
||||
settings = SimpleNamespace(
|
||||
SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS=300,
|
||||
SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS=1800,
|
||||
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=True,
|
||||
)
|
||||
|
||||
decision = _support_admin_notification_decision(ticket, settings, now=now)
|
||||
|
||||
assert decision.send_telegram is False
|
||||
assert decision.send_email is False
|
||||
|
||||
|
||||
def test_support_admin_notification_decision_uses_separate_email_cooldown():
|
||||
now = datetime(2026, 5, 20, tzinfo=timezone.utc)
|
||||
ticket = SimpleNamespace(
|
||||
unread_admin_count=4,
|
||||
admin_last_notified_at=now - timedelta(seconds=301),
|
||||
admin_last_emailed_at=now - timedelta(seconds=301),
|
||||
)
|
||||
settings = SimpleNamespace(
|
||||
SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS=300,
|
||||
SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS=1800,
|
||||
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=True,
|
||||
)
|
||||
|
||||
decision = _support_admin_notification_decision(ticket, settings, now=now)
|
||||
|
||||
assert decision.send_telegram is True
|
||||
assert decision.send_email is False
|
||||
Reference in New Issue
Block a user