v2: In-app support chat with Telegram notifications (REVIEW — not deployed)
Backend: - New models: SupportThread, SupportMessage (models/support.py) - Support router: user thread/messages, admin threads/reply, Telegram outbound - Context auto-attached: active program, points, gate status, last workout - Rate limited: 10 messages/user/day - Admin auth: username == 'scot' check - Telegram: fire-and-forget sendMessage via @AureusGoldBot, never blocks - config.py: added telegram_bot_token, telegram_chat_id Frontend: - Support.jsx: chat-style thread, send messages, confirmation, auto-scroll - SupportAdmin.jsx: thread list with unread badges, thread detail with context sidebar, reply - App.jsx: floating '?' help button with unread badge (polls every 60s), routes - api.js: support endpoints (user + admin) Config: - docker-compose.yml: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID env vars NOT DEPLOYED — requires Telegram env vars in Coolify
This commit is contained in:
parent
42632b1b50
commit
10f13a9f62
10 changed files with 988 additions and 4 deletions
|
|
@ -24,6 +24,10 @@ class Settings(BaseSettings):
|
|||
# Groq (program extraction)
|
||||
groq_api_key: str = ""
|
||||
|
||||
# Telegram (support notifications — outbound only)
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
|
||||
# App
|
||||
base_url: str = "https://fitness.littlefake.com"
|
||||
timezone: str = "Asia/Bangkok"
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ from app.routers.points import router as points_router, rewards_router
|
|||
from app.routers.integrations import router as integrations_router
|
||||
from app.routers.programs import router as programs_router
|
||||
from app.routers.catalog import router as catalog_router
|
||||
from app.routers.support import router as support_router
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(onboarding_router)
|
||||
|
|
@ -91,6 +92,7 @@ app.include_router(points_router)
|
|||
app.include_router(rewards_router)
|
||||
app.include_router(integrations_router)
|
||||
app.include_router(catalog_router)
|
||||
app.include_router(support_router)
|
||||
app.include_router(programs_router)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ from app.models.reward import Reward, RewardRedemption
|
|||
from app.models.oauth import OAuthToken
|
||||
from app.models.resource import Resource
|
||||
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
|
||||
from app.models.support import SupportThread, SupportMessage
|
||||
|
||||
__all__ = [
|
||||
"User", "UserProfile", "Program", "ActivityLog", "FoodLog",
|
||||
"PointRule", "DailyTarget", "Reward", "RewardRedemption",
|
||||
"OAuthToken", "Resource",
|
||||
"ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog",
|
||||
"SupportThread", "SupportMessage",
|
||||
]
|
||||
|
|
|
|||
54
backend/app/models/support.py
Normal file
54
backend/app/models/support.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Index, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class SupportThread(Base):
|
||||
"""One thread per user — private conversation with admin."""
|
||||
__tablename__ = "support_threads"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), unique=True, nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
last_message_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
unread_user: Mapped[int] = mapped_column(Integer, default=0)
|
||||
unread_admin: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
messages: Mapped[list["SupportMessage"]] = relationship(
|
||||
back_populates="thread", cascade="all, delete-orphan",
|
||||
order_by="SupportMessage.created_at"
|
||||
)
|
||||
|
||||
|
||||
class SupportMessage(Base):
|
||||
"""Individual message within a support thread."""
|
||||
__tablename__ = "support_messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
thread_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("support_threads.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
sender: Mapped[str] = mapped_column(String(10), nullable=False) # 'user' or 'admin'
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
context: Mapped[dict | None] = mapped_column(JSONB)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
thread: Mapped["SupportThread"] = relationship(back_populates="messages")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_support_messages_thread", "thread_id", "created_at"),
|
||||
)
|
||||
432
backend/app/routers/support.py
Normal file
432
backend/app/routers/support.py
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
"""Support chat router — user messaging + admin replies + Telegram notifications."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as uuid_mod
|
||||
from datetime import datetime, date, timezone, timedelta
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.support import SupportThread, SupportMessage
|
||||
from app.models.activity import ActivityLog
|
||||
from app.services.points_engine import get_active_program, get_today_status
|
||||
from app.utils.auth import get_current_user
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/support", tags=["support"])
|
||||
|
||||
ADMIN_USERNAME = "scot"
|
||||
MAX_MESSAGES_PER_DAY = 10
|
||||
|
||||
|
||||
# ── Schemas ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
body: str
|
||||
|
||||
class AdminReplyRequest(BaseModel):
|
||||
body: str
|
||||
|
||||
|
||||
# ── Context Gathering ───────────────────────────────────────────────────────
|
||||
|
||||
async def gather_user_context(db: AsyncSession, user: User) -> dict:
|
||||
"""Collect current user state for support context."""
|
||||
context = {
|
||||
"member_since": str(user.created_at.date()) if user.created_at else None,
|
||||
}
|
||||
|
||||
try:
|
||||
program = await get_active_program(db, user.id)
|
||||
if program:
|
||||
context["program_name"] = program.get("name")
|
||||
context["program_day"] = program.get("day")
|
||||
context["program_total_days"] = program.get("total_days")
|
||||
day = program.get("day", 0)
|
||||
total = program.get("total_days", 1)
|
||||
context["program_completion_pct"] = round((day / total * 100) if total > 0 else 0, 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
status = await get_today_status(db, user.id)
|
||||
context["points_today"] = status.get("points_earned", 0)
|
||||
context["daily_target"] = status.get("daily_minimum", 80)
|
||||
context["gate_passed"] = status.get("gate_passed", False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(ActivityLog)
|
||||
.where(ActivityLog.user_id == user.id, ActivityLog.category == "workout")
|
||||
.order_by(ActivityLog.logged_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
last = result.scalar_one_or_none()
|
||||
if last:
|
||||
context["last_workout"] = f"{last.activity_date} — {last.title or 'Workout'}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return context
|
||||
|
||||
|
||||
# ── Telegram Notification ──────────────────────────────────────────────────
|
||||
|
||||
def format_telegram_message(user_name: str, message: str, context: dict) -> str:
|
||||
"""Format the Telegram notification message."""
|
||||
lines = [f"🏋️ *Fitness Support*\n"]
|
||||
lines.append(f"From: *{user_name}*")
|
||||
|
||||
prog = context.get("program_name")
|
||||
if prog:
|
||||
day = context.get("program_day", "?")
|
||||
total = context.get("program_total_days", "?")
|
||||
pct = context.get("program_completion_pct", 0)
|
||||
lines.append(f"Program: {prog} (Day {day}/{total} — {pct}%)")
|
||||
|
||||
pts = context.get("points_today", 0)
|
||||
target = context.get("daily_target", 80)
|
||||
gate = "✅" if context.get("gate_passed") else "❌"
|
||||
lines.append(f"Points today: {pts}/{target} {gate}")
|
||||
|
||||
last = context.get("last_workout")
|
||||
if last:
|
||||
lines.append(f"Last workout: {last}")
|
||||
|
||||
lines.append(f"\n💬 \"{message}\"")
|
||||
lines.append(f"\n👉 https://fitness.littlefake.com/support/admin")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_telegram_notification(user_name: str, message: str, context: dict):
|
||||
"""Fire-and-forget Telegram notification. Never blocks or fails the request."""
|
||||
token = settings.telegram_bot_token
|
||||
chat_id = settings.telegram_chat_id
|
||||
|
||||
if not token or not chat_id:
|
||||
logger.warning("Telegram credentials not configured — skipping notification")
|
||||
return
|
||||
|
||||
text = format_telegram_message(user_name, message, context)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"https://api.telegram.org/bot{token}/sendMessage",
|
||||
json={
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
"disable_web_page_preview": True,
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Telegram send failed: {resp.status_code} {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Telegram notification error: {e}")
|
||||
|
||||
|
||||
# ── Helper: Get or Create Thread ───────────────────────────────────────────
|
||||
|
||||
async def get_or_create_thread(db: AsyncSession, user_id: uuid_mod.UUID) -> SupportThread:
|
||||
"""Get the user's support thread, creating one if it doesn't exist."""
|
||||
result = await db.execute(
|
||||
select(SupportThread)
|
||||
.options(selectinload(SupportThread.messages))
|
||||
.where(SupportThread.user_id == user_id)
|
||||
)
|
||||
thread = result.scalar_one_or_none()
|
||||
|
||||
if not thread:
|
||||
thread = SupportThread(user_id=user_id)
|
||||
db.add(thread)
|
||||
await db.flush()
|
||||
# Re-fetch with messages loaded
|
||||
result = await db.execute(
|
||||
select(SupportThread)
|
||||
.options(selectinload(SupportThread.messages))
|
||||
.where(SupportThread.id == thread.id)
|
||||
)
|
||||
thread = result.scalar_one_or_none()
|
||||
|
||||
return thread
|
||||
|
||||
|
||||
# ── User Endpoints ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/thread")
|
||||
async def get_thread(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get the user's support thread with all messages. Marks admin replies as read."""
|
||||
thread = await get_or_create_thread(db, user.id)
|
||||
|
||||
# Mark admin messages as read
|
||||
if thread.unread_user > 0:
|
||||
now = datetime.now(timezone.utc)
|
||||
await db.execute(
|
||||
update(SupportMessage)
|
||||
.where(
|
||||
SupportMessage.thread_id == thread.id,
|
||||
SupportMessage.sender == "admin",
|
||||
SupportMessage.read_at.is_(None),
|
||||
)
|
||||
.values(read_at=now)
|
||||
)
|
||||
thread.unread_user = 0
|
||||
await db.flush()
|
||||
|
||||
messages = [
|
||||
{
|
||||
"id": str(m.id),
|
||||
"sender": m.sender,
|
||||
"body": m.body,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
"read_at": m.read_at.isoformat() if m.read_at else None,
|
||||
}
|
||||
for m in thread.messages
|
||||
]
|
||||
|
||||
return {
|
||||
"thread_id": str(thread.id),
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/messages")
|
||||
async def send_message(
|
||||
req: SendMessageRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""User sends a support message. Auto-attaches context, fires Telegram notification."""
|
||||
body = req.body.strip()
|
||||
if not body:
|
||||
raise HTTPException(status_code=400, detail="Message cannot be empty")
|
||||
if len(body) > 2000:
|
||||
raise HTTPException(status_code=400, detail="Message too long (max 2000 characters)")
|
||||
|
||||
# Rate limit check
|
||||
today_start = datetime.combine(date.today(), datetime.min.time(), tzinfo=timezone.utc)
|
||||
thread = await get_or_create_thread(db, user.id)
|
||||
|
||||
result = await db.execute(
|
||||
select(func.count(SupportMessage.id)).where(
|
||||
SupportMessage.thread_id == thread.id,
|
||||
SupportMessage.sender == "user",
|
||||
SupportMessage.created_at >= today_start,
|
||||
)
|
||||
)
|
||||
count_today = result.scalar() or 0
|
||||
if count_today >= MAX_MESSAGES_PER_DAY:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Maximum {MAX_MESSAGES_PER_DAY} messages per day. Try again tomorrow."
|
||||
)
|
||||
|
||||
# Gather context
|
||||
context = await gather_user_context(db, user)
|
||||
|
||||
# Create message
|
||||
message = SupportMessage(
|
||||
thread_id=thread.id,
|
||||
sender="user",
|
||||
body=body,
|
||||
context=context,
|
||||
)
|
||||
db.add(message)
|
||||
thread.last_message_at = datetime.now(timezone.utc)
|
||||
thread.unread_admin += 1
|
||||
await db.flush()
|
||||
|
||||
# Fire Telegram notification (non-blocking)
|
||||
display_name = getattr(user, "display_name", None) or user.username
|
||||
await send_telegram_notification(display_name, body, context)
|
||||
|
||||
return {
|
||||
"id": str(message.id),
|
||||
"status": "sent",
|
||||
"message": "Message sent! We'll get back to you soon.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/unread")
|
||||
async def get_unread_count(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get unread count for the user (admin replies they haven't seen)."""
|
||||
result = await db.execute(
|
||||
select(SupportThread.unread_user).where(SupportThread.user_id == user.id)
|
||||
)
|
||||
count = result.scalar()
|
||||
return {"unread": count or 0}
|
||||
|
||||
|
||||
# ── Admin Endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
def require_admin(user: User):
|
||||
"""Check that the user is the admin."""
|
||||
if user.username != ADMIN_USERNAME:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
|
||||
@router.get("/admin/threads")
|
||||
async def list_threads(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""List all support threads with last message preview. Admin only."""
|
||||
require_admin(user)
|
||||
|
||||
result = await db.execute(
|
||||
select(SupportThread)
|
||||
.options(selectinload(SupportThread.messages))
|
||||
.order_by(SupportThread.last_message_at.desc())
|
||||
)
|
||||
threads = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for t in threads:
|
||||
# Get the user's display name
|
||||
from app.models.user import User as UserModel
|
||||
user_result = await db.execute(
|
||||
select(UserModel).where(UserModel.id == t.user_id)
|
||||
)
|
||||
thread_user = user_result.scalar_one_or_none()
|
||||
user_name = thread_user.display_name or thread_user.username if thread_user else "Unknown"
|
||||
|
||||
last_msg = t.messages[-1] if t.messages else None
|
||||
|
||||
out.append({
|
||||
"id": str(t.id),
|
||||
"user_id": str(t.user_id),
|
||||
"user_name": user_name,
|
||||
"unread_admin": t.unread_admin,
|
||||
"last_message": {
|
||||
"sender": last_msg.sender,
|
||||
"body": last_msg.body[:100] + ("..." if len(last_msg.body) > 100 else ""),
|
||||
"created_at": last_msg.created_at.isoformat(),
|
||||
} if last_msg else None,
|
||||
"message_count": len(t.messages),
|
||||
})
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/admin/threads/{thread_id}")
|
||||
async def get_admin_thread(
|
||||
thread_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get full thread with messages and context. Marks user messages as read. Admin only."""
|
||||
require_admin(user)
|
||||
|
||||
result = await db.execute(
|
||||
select(SupportThread)
|
||||
.options(selectinload(SupportThread.messages))
|
||||
.where(SupportThread.id == uuid_mod.UUID(thread_id))
|
||||
)
|
||||
thread = result.scalar_one_or_none()
|
||||
if not thread:
|
||||
raise HTTPException(status_code=404, detail="Thread not found")
|
||||
|
||||
# Mark user messages as read
|
||||
if thread.unread_admin > 0:
|
||||
now = datetime.now(timezone.utc)
|
||||
await db.execute(
|
||||
update(SupportMessage)
|
||||
.where(
|
||||
SupportMessage.thread_id == thread.id,
|
||||
SupportMessage.sender == "user",
|
||||
SupportMessage.read_at.is_(None),
|
||||
)
|
||||
.values(read_at=now)
|
||||
)
|
||||
thread.unread_admin = 0
|
||||
await db.flush()
|
||||
|
||||
# Get user info
|
||||
from app.models.user import User as UserModel
|
||||
user_result = await db.execute(
|
||||
select(UserModel).where(UserModel.id == thread.user_id)
|
||||
)
|
||||
thread_user = user_result.scalar_one_or_none()
|
||||
|
||||
# Get latest context from the most recent user message
|
||||
latest_context = None
|
||||
for m in reversed(thread.messages):
|
||||
if m.sender == "user" and m.context:
|
||||
latest_context = m.context
|
||||
break
|
||||
|
||||
messages = [
|
||||
{
|
||||
"id": str(m.id),
|
||||
"sender": m.sender,
|
||||
"body": m.body,
|
||||
"context": m.context if m.sender == "user" else None,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
"read_at": m.read_at.isoformat() if m.read_at else None,
|
||||
}
|
||||
for m in thread.messages
|
||||
]
|
||||
|
||||
return {
|
||||
"thread_id": str(thread.id),
|
||||
"user_name": thread_user.display_name or thread_user.username if thread_user else "Unknown",
|
||||
"user_id": str(thread.user_id),
|
||||
"latest_context": latest_context,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/admin/threads/{thread_id}/reply")
|
||||
async def reply_to_thread(
|
||||
thread_id: str,
|
||||
req: AdminReplyRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Admin sends a reply to a user's support thread."""
|
||||
require_admin(user)
|
||||
|
||||
body = req.body.strip()
|
||||
if not body:
|
||||
raise HTTPException(status_code=400, detail="Reply cannot be empty")
|
||||
|
||||
result = await db.execute(
|
||||
select(SupportThread).where(SupportThread.id == uuid_mod.UUID(thread_id))
|
||||
)
|
||||
thread = result.scalar_one_or_none()
|
||||
if not thread:
|
||||
raise HTTPException(status_code=404, detail="Thread not found")
|
||||
|
||||
message = SupportMessage(
|
||||
thread_id=thread.id,
|
||||
sender="admin",
|
||||
body=body,
|
||||
)
|
||||
db.add(message)
|
||||
thread.last_message_at = datetime.now(timezone.utc)
|
||||
thread.unread_user += 1
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"id": str(message.id),
|
||||
"status": "sent",
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue