메일 알림 시스템 추가

This commit is contained in:
Yun Chan 2026-06-29 17:07:01 +09:00
parent ddf12a851c
commit 3bf38c50df
22 changed files with 1769 additions and 31 deletions

View file

@ -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,

View file

@ -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:

View file

@ -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)