메일 알림 시스템 추가
This commit is contained in:
parent
ddf12a851c
commit
3bf38c50df
22 changed files with 1769 additions and 31 deletions
|
|
@ -31,7 +31,7 @@ from ..deps import Principal, require_admin_access
|
|||
from ..engine_client import engine_client
|
||||
from ..runtime_policy import require_runtime_fallback_allowed
|
||||
from ..services.voice import voice_service
|
||||
from ..services import evaluator, rag
|
||||
from ..services import evaluator, notifications, rag
|
||||
from ..store import store
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
|
@ -49,6 +49,7 @@ TicketCategory = Literal[
|
|||
]
|
||||
TicketPriority = Literal["low", "normal", "high", "urgent"]
|
||||
TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"]
|
||||
NotificationDeliveryStatus = Literal["queued", "sending", "sent", "failed", "skipped"]
|
||||
|
||||
|
||||
class AdminServiceHealth(BaseModel):
|
||||
|
|
@ -258,6 +259,46 @@ class AdminUserPatch(BaseModel):
|
|||
cohort_ids: list[str] | None = None
|
||||
|
||||
|
||||
class AdminNotificationDeliveryResponse(BaseModel):
|
||||
id: str
|
||||
event_id: str
|
||||
kind: str
|
||||
recipient_email: str
|
||||
recipient_name: str
|
||||
recipient_role: str
|
||||
subject: str
|
||||
status: NotificationDeliveryStatus
|
||||
attempts: int
|
||||
last_error: str | None = None
|
||||
provider_message_id: str | None = None
|
||||
created_at: float | None = None
|
||||
updated_at: float | None = None
|
||||
sent_at: float | None = None
|
||||
|
||||
|
||||
class AdminNotificationsResponse(BaseModel):
|
||||
provider: str
|
||||
smtp_configured: bool
|
||||
queued: int
|
||||
failed: int
|
||||
sent: int
|
||||
skipped: int
|
||||
deliveries: list[AdminNotificationDeliveryResponse]
|
||||
|
||||
|
||||
class AdminNotificationProcessResponse(BaseModel):
|
||||
processed: int
|
||||
sent: int
|
||||
failed: int
|
||||
skipped: int
|
||||
|
||||
|
||||
class AdminNotificationTestResponse(AdminNotificationProcessResponse):
|
||||
provider: str
|
||||
smtp_configured: bool
|
||||
recipients: int
|
||||
|
||||
|
||||
class RuntimeHealthMetrics(BaseModel):
|
||||
engine_latency_ms: float | None = None
|
||||
db_pool_size: int = 0
|
||||
|
|
@ -1009,6 +1050,25 @@ def _row_value(row, key: str, default=None):
|
|||
return default
|
||||
|
||||
|
||||
def _notification_delivery_response(row) -> AdminNotificationDeliveryResponse:
|
||||
return AdminNotificationDeliveryResponse(
|
||||
id=str(row["id"]),
|
||||
event_id=str(row["event_id"]),
|
||||
kind=str(row["kind"]),
|
||||
recipient_email=str(row["recipient_email"]),
|
||||
recipient_name=str(row["recipient_name"] or ""),
|
||||
recipient_role=str(row["recipient_role"] or ""),
|
||||
subject=str(row["subject"] or ""),
|
||||
status=row["status"],
|
||||
attempts=int(row["attempts"] or 0),
|
||||
last_error=str(row["last_error"] or "") or None,
|
||||
provider_message_id=str(row["provider_message_id"] or "") or None,
|
||||
created_at=_row_ts(row["created_at"]),
|
||||
updated_at=_row_ts(row["updated_at"]),
|
||||
sent_at=_row_ts(row["sent_at"]),
|
||||
)
|
||||
|
||||
|
||||
def _engine_config_from_row(row) -> AdminEngineConfigResponse:
|
||||
return AdminEngineConfigResponse(
|
||||
engine_mode=_normalize_engine_mode(row["engine_mode"]),
|
||||
|
|
@ -1456,6 +1516,101 @@ async def list_tickets(
|
|||
return _unavailable_tickets()
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=AdminNotificationsResponse)
|
||||
async def list_notifications(
|
||||
principal: AdminPrincipal,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
) -> AdminNotificationsResponse:
|
||||
"""Return recent operational email delivery state."""
|
||||
try:
|
||||
get_pool()
|
||||
async with acquire(role="admin") as conn:
|
||||
status_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT status, COUNT(*) AS count
|
||||
FROM app.notification_delivery
|
||||
GROUP BY status
|
||||
"""
|
||||
)
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
d.id,
|
||||
d.event_id,
|
||||
e.kind,
|
||||
d.recipient_email,
|
||||
d.recipient_name,
|
||||
d.recipient_role,
|
||||
d.subject,
|
||||
d.status,
|
||||
d.attempts,
|
||||
d.last_error,
|
||||
d.provider_message_id,
|
||||
d.created_at,
|
||||
d.updated_at,
|
||||
d.sent_at
|
||||
FROM app.notification_delivery d
|
||||
JOIN app.notification_event e ON e.id = d.event_id
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT $1
|
||||
""",
|
||||
limit,
|
||||
)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("admin notifications")
|
||||
status_rows = []
|
||||
rows = []
|
||||
counts = {str(row["status"]): int(row["count"] or 0) for row in status_rows}
|
||||
return AdminNotificationsResponse(
|
||||
provider=settings.notification_email_provider,
|
||||
smtp_configured=bool(settings.smtp_host and settings.smtp_from_email),
|
||||
queued=counts.get("queued", 0),
|
||||
failed=counts.get("failed", 0),
|
||||
sent=counts.get("sent", 0),
|
||||
skipped=counts.get("skipped", 0),
|
||||
deliveries=[_notification_delivery_response(row) for row in rows],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/notifications/process", response_model=AdminNotificationProcessResponse)
|
||||
async def process_notifications(
|
||||
principal: AdminPrincipal,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 25,
|
||||
) -> AdminNotificationProcessResponse:
|
||||
"""Drain queued email notifications once from the admin console/API."""
|
||||
try:
|
||||
result = await notifications.process_queued_email_notifications(limit=limit)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("admin notification processing")
|
||||
result = {"processed": 0, "sent": 0, "failed": 0, "skipped": 0}
|
||||
return AdminNotificationProcessResponse(**result)
|
||||
|
||||
|
||||
@router.post("/notifications/test", response_model=AdminNotificationTestResponse)
|
||||
async def send_test_notification(
|
||||
principal: AdminPrincipal,
|
||||
) -> AdminNotificationTestResponse:
|
||||
"""Queue and process one explicit admin email test."""
|
||||
recipient_count = 0
|
||||
try:
|
||||
recipient_count = await notifications.enqueue_admin_test_email(
|
||||
actor_user_id=principal.user_id,
|
||||
actor_email=principal.email,
|
||||
)
|
||||
result = await notifications.process_queued_email_notifications(
|
||||
limit=max(1, min(100, recipient_count or 1))
|
||||
)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("admin notification test")
|
||||
result = {"processed": 0, "sent": 0, "failed": 0, "skipped": 0}
|
||||
return AdminNotificationTestResponse(
|
||||
provider=settings.notification_email_provider,
|
||||
smtp_configured=bool(settings.smtp_host and settings.smtp_from_email),
|
||||
recipients=recipient_count,
|
||||
**result,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/tickets/{ticket_id}", response_model=AdminSupportTicketResponse)
|
||||
async def patch_ticket(
|
||||
ticket_id: str,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from ..services import (
|
|||
guardrail,
|
||||
live_coach,
|
||||
memory,
|
||||
notifications,
|
||||
orchestrator,
|
||||
rag,
|
||||
session_digest_worker,
|
||||
|
|
@ -437,7 +438,7 @@ async def _warm_rag_caches(session_id: str, case_id: str, card) -> None:
|
|||
def _ensure_learner(principal: Principal) -> Principal:
|
||||
if principal.role == Role.LEARNER:
|
||||
return principal
|
||||
if principal.super_admin:
|
||||
if principal.can_access_role(Role.LEARNER):
|
||||
return principal.with_role(Role.LEARNER)
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only learners can use sessions")
|
||||
|
||||
|
|
@ -654,15 +655,17 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
),
|
||||
timeout=min(float(settings.engine_timeout), 45.0),
|
||||
)
|
||||
await session_persistence.save_session_evaluation(
|
||||
saved = await session_persistence.save_session_evaluation(
|
||||
session_persistence.SessionEvaluationWrite.from_result(
|
||||
session_id=sess.session_id,
|
||||
learner_id=sess.learner_id,
|
||||
result=result,
|
||||
)
|
||||
)
|
||||
if saved:
|
||||
await _enqueue_session_review_ready_notification(sess.session_id)
|
||||
except Exception as exc:
|
||||
await session_persistence.save_session_evaluation(
|
||||
saved = await session_persistence.save_session_evaluation(
|
||||
session_persistence.SessionEvaluationWrite.from_error(
|
||||
session_id=sess.session_id,
|
||||
learner_id=sess.learner_id,
|
||||
|
|
@ -671,6 +674,15 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
error=str(exc),
|
||||
)
|
||||
)
|
||||
if saved:
|
||||
await _enqueue_session_review_ready_notification(sess.session_id)
|
||||
|
||||
|
||||
async def _enqueue_session_review_ready_notification(session_id: str) -> None:
|
||||
try:
|
||||
await notifications.enqueue_session_review_ready(session_id=session_id)
|
||||
except Exception as exc:
|
||||
logger.warning("session review notification enqueue failed: %s", exc)
|
||||
|
||||
|
||||
def _schedule_session_evaluation(sess: InProcSession) -> None:
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ class NotificationPreferences(BaseModel):
|
|||
session_done: bool = True
|
||||
safety_signal: bool = True
|
||||
learner_progress: bool = False
|
||||
account_approval: bool = True
|
||||
product_news: bool = False
|
||||
|
||||
|
||||
|
|
@ -580,7 +581,7 @@ async def complete_onboarding(
|
|||
|
||||
principal.display_name = updated.display_name
|
||||
principal.profile_completed_at = updated.profile_completed_at
|
||||
if (principal.role == Role.LEARNER or principal.super_admin) and principal.consent_at is None:
|
||||
if principal.can_access_role(Role.LEARNER) and principal.consent_at is None:
|
||||
principal.consent_at = await record_user_consent(principal.user_id)
|
||||
return await _profile_for(principal)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue