메일 알림 시스템 추가

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

@ -9,6 +9,7 @@ from __future__ import annotations
import hashlib
import hmac
import logging
import secrets
import time
import uuid
@ -19,6 +20,9 @@ from .auth_types import AccountStatus, RoleName
from .config import settings
from .db import get_pool
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from .services import notifications
logger = logging.getLogger(__name__)
@dataclass(slots=True)
@ -351,6 +355,24 @@ async def _runtime_tables_ready(conn) -> bool:
AND table_name = 'session_state'
AND column_name = 'turns_in_stage'
) AS has_state_columns,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'turns'
AND column_name = 'provider_events'
) AS has_turn_provider_events,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'session_review_status'
AND column_name IN (
'worksheet_status',
'worksheet_note',
'worksheet_reviewed_at'
)
GROUP BY table_schema, table_name
HAVING count(*) = 3
) AS has_session_review_worksheet_columns,
(
SELECT count(*) = 4
FROM app.stage_def
@ -463,6 +485,8 @@ async def _runtime_tables_ready(conn) -> bool:
and row["has_engine_config"]
and row["has_session_columns"]
and row["has_state_columns"]
and row["has_turn_provider_events"]
and row["has_session_review_worksheet_columns"]
and row["has_stage_defs"]
and row["has_admin_health_event"]
and row["has_admin_health_daily_rollup"]
@ -1813,6 +1837,16 @@ async def create_session(
reactivate=False,
)
)
if managed.account_status == "pending":
try:
await notifications.enqueue_account_pending_approval(
user_id=managed.user_id,
email=managed.email,
display_name=managed.display_name,
role=managed.role,
)
except Exception as exc:
logger.warning("account approval notification enqueue failed: %s", exc)
expires_at = time.time() + settings.session_ttl_seconds
user = SessionUser(
user_id=managed.user_id,

View file

@ -219,6 +219,28 @@ class Settings(BaseSettings):
validation_alias="USER_UPLOAD_DIR",
)
# ── 운영 메일 알림 ─────────────────────────────────────
notification_email_provider: Literal["disabled", "smtp"] = Field(
default="disabled",
validation_alias="NOTIFICATION_EMAIL_PROVIDER",
)
notification_email_max_attempts: int = Field(
default=3,
validation_alias="NOTIFICATION_EMAIL_MAX_ATTEMPTS",
)
notification_email_retry_seconds: int = Field(
default=900,
validation_alias="NOTIFICATION_EMAIL_RETRY_SECONDS",
)
smtp_host: str = Field(default="", validation_alias="SMTP_HOST")
smtp_port: int = Field(default=587, validation_alias="SMTP_PORT")
smtp_username: str = Field(default="", validation_alias="SMTP_USERNAME")
smtp_password: str = Field(default="", validation_alias="SMTP_PASSWORD")
smtp_from_email: str = Field(default="", validation_alias="SMTP_FROM_EMAIL")
smtp_from_name: str = Field(default="Vignette", validation_alias="SMTP_FROM_NAME")
smtp_starttls: bool = Field(default=True, validation_alias="SMTP_STARTTLS")
smtp_ssl: bool = Field(default=False, validation_alias="SMTP_SSL")
# ── CORS (정적 프론트 + SSE 분리경로) ────────────────
cors_origins: list[str] = Field(
default=[
@ -278,9 +300,16 @@ class Settings(BaseSettings):
for origin in self.frontend_origin_map.values()
):
forbidden.append("FRONTEND_ORIGIN_MAP")
if self.notification_email_provider == "smtp":
if not self.smtp_host.strip():
forbidden.append("SMTP_HOST")
if not self.smtp_from_email.strip():
forbidden.append("SMTP_FROM_EMAIL")
if forbidden:
joined = ", ".join(forbidden)
raise ValueError(f"{joined} must be production-safe when ENVIRONMENT={self.environment}")
if self.smtp_ssl and self.smtp_starttls:
raise ValueError("SMTP_SSL and SMTP_STARTTLS cannot both be true")
if self.auth_saml_enabled:
missing_saml: list[str] = []
if not self.saml_sp_entity_id.strip():

View file

@ -151,7 +151,30 @@ async def healthcheck() -> bool:
to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config,
to_regclass('app.admin_health_event') IS NOT NULL AS has_admin_health_event,
to_regclass('app.admin_health_daily_rollup') IS NOT NULL AS has_admin_health_daily_rollup,
to_regclass('app.support_ticket') IS NOT NULL AS has_support_ticket
to_regclass('app.support_ticket') IS NOT NULL AS has_support_ticket,
to_regclass('app.notification_event') IS NOT NULL AS has_notification_event,
to_regclass('app.notification_delivery') IS NOT NULL AS has_notification_delivery,
to_regclass('app.sessions') IS NOT NULL AS has_sessions,
to_regclass('app.turns') IS NOT NULL AS has_turns,
to_regclass('app.session_review_status') IS NOT NULL AS has_session_review_status,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'turns'
AND column_name = 'provider_events'
) AS has_turn_provider_events,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'session_review_status'
AND column_name IN (
'worksheet_status',
'worksheet_note',
'worksheet_reviewed_at'
)
GROUP BY table_schema, table_name
HAVING count(*) = 3
) AS has_session_review_worksheet_columns
"""
)
return bool(
@ -163,6 +186,13 @@ async def healthcheck() -> bool:
and row["has_admin_health_event"]
and row["has_admin_health_daily_rollup"]
and row["has_support_ticket"]
and row["has_notification_event"]
and row["has_notification_delivery"]
and row["has_sessions"]
and row["has_turns"]
and row["has_session_review_status"]
and row["has_turn_provider_events"]
and row["has_session_review_worksheet_columns"]
)
except Exception:
return False

View file

@ -31,6 +31,7 @@ from .routes import share as share_routes
from .routes import teacher as teacher_routes
from .routes import users as user_routes
from .routes import voice as voice_routes
from .services.notifications import ensure_notification_tables
from .services.voice import voice_service
@ -44,6 +45,7 @@ async def lifespan(app: FastAPI):
await init_pool()
await ensure_runtime_tables()
await ensure_review_tables()
await ensure_notification_tables()
if settings.auto_seed_personas:
await materialize_seed_personas()
await admin_routes.apply_engine_config_from_store()

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)

View file

@ -0,0 +1,744 @@
"""Operational email notifications for review and account-approval work."""
from __future__ import annotations
import asyncio
import html
import logging
import smtplib
import ssl
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr, make_msgid
from typing import Any, Literal
from uuid import uuid4
from ..config import settings
from ..db import acquire, get_pool
logger = logging.getLogger(__name__)
NotificationKind = Literal["account_pending_approval", "session_review_ready", "admin_test_email"]
@dataclass(slots=True)
class NotificationRecipient:
user_id: str | None
email: str
display_name: str
role: str
@dataclass(slots=True)
class RenderedEmail:
subject: str
preheader: str
html: str
text: str
async def ensure_notification_tables() -> None:
"""Create notification queue tables when the DB role allows DDL."""
get_pool()
async with acquire(role="admin") as conn:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.notification_event (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kind TEXT NOT NULL CHECK (
kind IN ('account_pending_approval','session_review_ready','admin_test_email')
),
actor_user_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL,
subject_user_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL,
session_id UUID REFERENCES app.sessions(id) ON DELETE CASCADE,
idempotency_key TEXT NOT NULL UNIQUE,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.notification_delivery (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES app.notification_event(id) ON DELETE CASCADE,
channel TEXT NOT NULL DEFAULT 'email' CHECK (channel IN ('email')),
recipient_user_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL,
recipient_email TEXT NOT NULL,
recipient_name TEXT NOT NULL DEFAULT '',
recipient_role TEXT NOT NULL DEFAULT '',
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued' CHECK (
status IN ('queued','sending','sent','failed','skipped')
),
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ,
last_error TEXT,
provider_message_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (event_id, channel, recipient_email)
)
"""
)
await conn.execute(
"""
ALTER TABLE app.notification_event
DROP CONSTRAINT IF EXISTS notification_event_kind_check;
ALTER TABLE app.notification_event
ADD CONSTRAINT notification_event_kind_check
CHECK (kind IN ('account_pending_approval','session_review_ready','admin_test_email'));
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_notification_event_created
ON app.notification_event(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notification_delivery_queue
ON app.notification_delivery(status, next_attempt_at, created_at)
WHERE status IN ('queued','failed');
CREATE INDEX IF NOT EXISTS idx_notification_delivery_event
ON app.notification_delivery(event_id, created_at);
"""
)
await conn.execute(
"""
ALTER TABLE app.notification_event ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.notification_delivery ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_notification_event_admin_all ON app.notification_event;
DROP POLICY IF EXISTS p_notification_delivery_admin_all ON app.notification_delivery;
CREATE POLICY p_notification_event_admin_all
ON app.notification_event FOR ALL
USING (app.current_role_name() = 'admin')
WITH CHECK (app.current_role_name() = 'admin');
CREATE POLICY p_notification_delivery_admin_all
ON app.notification_delivery FOR ALL
USING (app.current_role_name() = 'admin')
WITH CHECK (app.current_role_name() = 'admin');
"""
)
def schedule_delivery_flush() -> None:
"""Best-effort background drain; failed SMTP remains queued for a worker."""
if settings.notification_email_provider == "disabled":
return
try:
asyncio.get_running_loop().create_task(process_queued_email_notifications(limit=10))
except RuntimeError:
return
async def enqueue_account_pending_approval(
*,
user_id: str,
email: str,
display_name: str,
role: str,
) -> None:
"""Notify administrators that a newly signed-in account needs approval."""
if not user_id or not email:
return
payload = {
"user_id": user_id,
"email": email,
"display_name": display_name or email,
"role": role,
"approval_url": _frontend_url("/admin/users"),
}
recipients = await _admin_approval_recipients()
await _enqueue_event(
kind="account_pending_approval",
idempotency_key=f"account_pending_approval:{user_id}",
subject_user_id=user_id,
session_id=None,
payload=payload,
recipients=recipients,
)
schedule_delivery_flush()
async def enqueue_session_review_ready(*, session_id: str) -> None:
"""Notify assigned teachers/admins that an ended session is ready to review."""
if not session_id:
return
payload, recipients = await _session_review_payload_and_recipients(session_id)
if payload is None:
return
await _enqueue_event(
kind="session_review_ready",
idempotency_key=f"session_review_ready:{session_id}",
subject_user_id=str(payload.get("learner_id") or "") or None,
session_id=session_id,
payload=payload,
recipients=recipients,
)
schedule_delivery_flush()
async def enqueue_admin_test_email(
*,
actor_user_id: str | None = None,
actor_email: str = "",
) -> int:
"""Queue one explicit admin test email through the normal delivery path."""
payload = {
"requested_by": actor_email,
"notifications_url": _frontend_url("/admin"),
}
recipients = await _admin_approval_recipients()
await _enqueue_event(
kind="admin_test_email",
idempotency_key=f"admin_test_email:{uuid4()}",
subject_user_id=actor_user_id,
session_id=None,
payload=payload,
recipients=recipients,
)
schedule_delivery_flush()
return len(recipients)
async def process_queued_email_notifications(*, limit: int = 25) -> dict[str, int]:
"""Send queued email deliveries and record durable delivery state."""
get_pool()
processed = 0
sent = 0
failed = 0
skipped = 0
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
SELECT
d.id,
d.recipient_email,
d.recipient_name,
d.attempts,
e.kind,
e.payload
FROM app.notification_delivery d
JOIN app.notification_event e ON e.id = d.event_id
WHERE d.channel = 'email'
AND d.status IN ('queued','failed')
AND d.next_attempt_at <= now()
AND d.attempts < $1
ORDER BY d.created_at
LIMIT $2
""",
settings.notification_email_max_attempts,
limit,
)
for row in rows:
delivery_id = str(row["id"])
processed += 1
await _mark_delivery_sending(delivery_id)
rendered = render_email(row["kind"], row["payload"] or {})
try:
provider_id = await asyncio.to_thread(
_send_rendered_email,
recipient_email=str(row["recipient_email"]),
recipient_name=str(row["recipient_name"] or ""),
rendered=rendered,
)
except NotificationSkipped as exc:
skipped += 1
await _mark_delivery_skipped(delivery_id, str(exc))
except Exception as exc: # SMTP/library errors should not break app flows.
failed += 1
await _mark_delivery_failed(delivery_id, str(exc))
else:
sent += 1
await _mark_delivery_sent(delivery_id, provider_id)
return {"processed": processed, "sent": sent, "failed": failed, "skipped": skipped}
def render_email(kind: str, payload: dict[str, Any]) -> RenderedEmail:
if kind == "account_pending_approval":
return _render_account_pending_approval(payload)
if kind == "session_review_ready":
return _render_session_review_ready(payload)
if kind == "admin_test_email":
return _render_admin_test_email(payload)
raise ValueError(f"unsupported notification kind: {kind}")
async def _enqueue_event(
*,
kind: NotificationKind,
idempotency_key: str,
subject_user_id: str | None,
session_id: str | None,
payload: dict[str, Any],
recipients: list[NotificationRecipient],
) -> None:
if not recipients:
logger.warning("notification %s has no recipients", idempotency_key)
get_pool()
rendered = render_email(kind, payload)
async with acquire(role="admin") as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.notification_event (
kind, subject_user_id, session_id, idempotency_key, payload
)
VALUES ($1, $2::uuid, $3::uuid, $4, $5::jsonb)
ON CONFLICT (idempotency_key) DO UPDATE SET
payload = app.notification_event.payload
RETURNING id
""",
kind,
subject_user_id,
session_id,
idempotency_key,
payload,
)
if row is None:
return
event_id = str(row["id"])
for recipient in recipients:
await conn.execute(
"""
INSERT INTO app.notification_delivery (
event_id, recipient_user_id, recipient_email, recipient_name,
recipient_role, subject
)
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6)
ON CONFLICT (event_id, channel, recipient_email) DO NOTHING
""",
event_id,
recipient.user_id,
recipient.email,
recipient.display_name,
recipient.role,
rendered.subject,
)
async def _admin_approval_recipients() -> list[NotificationRecipient]:
super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
SELECT DISTINCT
u.user_id,
u.email,
COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
u.role
FROM app.app_user u
LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
WHERE u.is_active
AND u.account_status = 'approved'
AND u.email IS NOT NULL
AND u.email <> ''
AND (
u.role = 'admin'
OR u.admin_access
OR lower(u.email) = ANY($1::text[])
)
AND COALESCE((p.notifications->>'account_approval')::boolean, true)
ORDER BY u.email
""",
super_admin_emails,
)
return [_recipient_from_row(row) for row in rows]
async def _session_review_payload_and_recipients(
session_id: str,
) -> tuple[dict[str, Any] | None, list[NotificationRecipient]]:
super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
async with acquire(role="admin") as conn:
session = await conn.fetchrow(
"""
SELECT
s.id,
s.session_no,
s.ended_at,
s.learner_id,
s.persona_id,
learner.email AS learner_email,
COALESCE(NULLIF(learner.display_name, ''), learner.email, '학습자') AS learner_label,
learner.cohort AS learner_cohort,
COALESCE(NULLIF(pc.display_name, ''), s.persona_id::text) AS persona_name
FROM app.sessions s
LEFT JOIN app.app_user learner ON learner.user_id = s.learner_id
LEFT JOIN app.persona_card pc
ON pc.persona_id = s.persona_id
AND pc.version = s.persona_version
WHERE s.id = $1::uuid
AND s.ended_at IS NOT NULL
""",
session_id,
)
if session is None:
return None, []
learner_cohort = str(session["learner_cohort"] or "")
rows = await conn.fetch(
"""
SELECT DISTINCT
u.user_id,
u.email,
COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
u.role
FROM app.app_user u
LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
WHERE u.is_active
AND u.account_status = 'approved'
AND u.email IS NOT NULL
AND u.email <> ''
AND (
u.role = 'admin'
OR lower(u.email) = ANY($1::text[])
OR (
u.role = 'instructor'
AND ($2 = '' OR u.cohort = $2)
)
)
AND COALESCE((p.notifications->>'learner_progress')::boolean, true)
ORDER BY u.email
""",
super_admin_emails,
learner_cohort,
)
payload = {
"session_id": str(session["id"]),
"session_no": int(session["session_no"] or 0),
"ended_at": _iso_text(session["ended_at"]),
"learner_id": str(session["learner_id"] or ""),
"learner_email": str(session["learner_email"] or ""),
"learner_label": str(session["learner_label"] or "학습자"),
"learner_cohort": learner_cohort,
"persona_id": str(session["persona_id"] or ""),
"persona_name": str(session["persona_name"] or "내담자"),
"review_url": _frontend_url(f"/teach/session/{session_id}/review"),
}
return payload, [_recipient_from_row(row) for row in rows]
def _send_rendered_email(
*,
recipient_email: str,
recipient_name: str,
rendered: RenderedEmail,
) -> str:
if settings.notification_email_provider == "disabled":
raise NotificationSkipped("email provider is disabled")
if settings.notification_email_provider != "smtp":
raise NotificationSkipped(f"unsupported provider: {settings.notification_email_provider}")
if not settings.smtp_host.strip() or not settings.smtp_from_email.strip():
raise NotificationSkipped("SMTP host/from email is not configured")
message_id = make_msgid(domain=_message_id_domain(settings.smtp_from_email))
msg = EmailMessage()
msg["Subject"] = rendered.subject
msg["From"] = formataddr((settings.smtp_from_name, settings.smtp_from_email))
msg["To"] = formataddr((recipient_name, recipient_email))
msg["Message-ID"] = message_id
msg.set_content(rendered.text)
msg.add_alternative(rendered.html, subtype="html")
if settings.smtp_ssl:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, context=context, timeout=15) as smtp:
_smtp_login_if_needed(smtp)
smtp.send_message(msg)
else:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as smtp:
if settings.smtp_starttls:
smtp.starttls(context=ssl.create_default_context())
_smtp_login_if_needed(smtp)
smtp.send_message(msg)
return message_id
def _smtp_login_if_needed(smtp: smtplib.SMTP) -> None:
if settings.smtp_username:
smtp.login(settings.smtp_username, settings.smtp_password)
async def _mark_delivery_sending(delivery_id: str) -> None:
async with acquire(role="admin") as conn:
await conn.execute(
"""
UPDATE app.notification_delivery
SET status = 'sending',
attempts = attempts + 1,
updated_at = now()
WHERE id = $1::uuid
""",
delivery_id,
)
async def _mark_delivery_sent(delivery_id: str, provider_id: str) -> None:
async with acquire(role="admin") as conn:
await conn.execute(
"""
UPDATE app.notification_delivery
SET status = 'sent',
sent_at = now(),
last_error = NULL,
provider_message_id = $2,
updated_at = now()
WHERE id = $1::uuid
""",
delivery_id,
provider_id,
)
async def _mark_delivery_skipped(delivery_id: str, reason: str) -> None:
async with acquire(role="admin") as conn:
await conn.execute(
"""
UPDATE app.notification_delivery
SET status = 'skipped',
last_error = $2,
updated_at = now()
WHERE id = $1::uuid
""",
delivery_id,
reason[:1000],
)
async def _mark_delivery_failed(delivery_id: str, error: str) -> None:
async with acquire(role="admin") as conn:
await conn.execute(
"""
UPDATE app.notification_delivery
SET status = 'failed',
last_error = $2,
next_attempt_at = now() + ($3::text || ' seconds')::interval,
updated_at = now()
WHERE id = $1::uuid
""",
delivery_id,
error[:1000],
settings.notification_email_retry_seconds,
)
def _render_account_pending_approval(payload: dict[str, Any]) -> RenderedEmail:
display_name = str(payload.get("display_name") or payload.get("email") or "신규 사용자")
email = str(payload.get("email") or "")
role = _role_label(str(payload.get("role") or "learner"))
approval_url = str(payload.get("approval_url") or _frontend_url("/admin/users"))
subject = "[Vignette] 새 가입 승인 요청이 있습니다"
preheader = "승인 대기 중인 신규 사용자가 있습니다."
html_body = f"""
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
승인 대기 중인 신규 사용자가 있습니다. 가입 승인 화면에서 수업 또는 연구 참여 범위를 확인한 승인 상태를 결정해 주세요.
</p>
{_info_box([
("사용자", display_name),
("이메일", email),
("요청 역할", role),
])}
{_button("가입 승인 확인하기", approval_url)}
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
계정 승인 전까지 해당 사용자는 Vignette 대기 화면만 있습니다.
</p>
"""
html_doc = _email_shell(
kicker="관리자 알림",
title="새 가입 승인 요청이 있습니다.",
preheader=preheader,
body=html_body,
)
text = (
"Vignette 관리자 알림\n\n"
"새 가입 승인 요청이 있습니다.\n"
f"사용자: {display_name}\n"
f"이메일: {email}\n"
f"요청 역할: {role}\n\n"
f"가입 승인 확인하기: {approval_url}\n"
)
return RenderedEmail(subject=subject, preheader=preheader, html=html_doc, text=text)
def _render_session_review_ready(payload: dict[str, Any]) -> RenderedEmail:
learner_label = str(payload.get("learner_label") or "학습자")
persona_name = str(payload.get("persona_name") or "내담자")
session_no = str(payload.get("session_no") or "")
ended_at = str(payload.get("ended_at") or "")
review_url = str(payload.get("review_url") or _frontend_url("/teach"))
subject = "[Vignette] 검토할 회기가 있습니다"
preheader = "종료된 학습 회기를 확인하고 검토 상태를 남겨 주세요."
html_body = f"""
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
종료된 학습 회기가 교수자 검토 대기 상태입니다. 회기 리뷰 화면에서 요약과 근거를 확인한 검토 상태를 남겨 주세요.
</p>
{_info_box([
("학습자", learner_label),
("내담자", persona_name),
("회기", f"{session_no}회기" if session_no else "종료 회기"),
("종료 시각", ended_at or "기록됨"),
])}
{_button("회기 검토하기", review_url)}
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
민감한 회기 내용은 메일에 포함하지 않았습니다. 로그인 Vignette에서 확인해 주세요.
</p>
"""
html_doc = _email_shell(
kicker="교수자 검토 알림",
title="검토할 회기가 있습니다.",
preheader=preheader,
body=html_body,
)
text = (
"Vignette 교수자 검토 알림\n\n"
"검토할 회기가 있습니다.\n"
f"학습자: {learner_label}\n"
f"내담자: {persona_name}\n"
f"회기: {session_no}회기\n"
f"종료 시각: {ended_at}\n\n"
f"회기 검토하기: {review_url}\n"
"\n민감한 회기 내용은 메일에 포함하지 않았습니다.\n"
)
return RenderedEmail(subject=subject, preheader=preheader, html=html_doc, text=text)
def _render_admin_test_email(payload: dict[str, Any]) -> RenderedEmail:
requested_by = str(payload.get("requested_by") or "관리자")
notifications_url = str(payload.get("notifications_url") or _frontend_url("/admin"))
subject = "[Vignette] 메일 알림 테스트입니다"
preheader = "관리자 메일 알림 경로가 정상적으로 연결되었는지 확인합니다."
html_body = f"""
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
관리자 메일 알림이 정상적으로 연결되었습니다. 메일은 실제 가입 승인이나 회기 검토 요청이 아니라 발송 경로 확인용 테스트입니다.
</p>
{_info_box([
("요청자", requested_by),
("용도", "운영 메일 발송 테스트"),
])}
{_button("알림 상태 확인하기", notifications_url)}
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
이후 가입 승인 요청과 회기 검토 요청도 같은 메일 템플릿과 발송 큐를 사용합니다.
</p>
"""
html_doc = _email_shell(
kicker="관리자 테스트 알림",
title="메일 알림 테스트입니다.",
preheader=preheader,
body=html_body,
)
text = (
"Vignette 관리자 테스트 알림\n\n"
"메일 알림 테스트입니다.\n"
"관리자 메일 알림이 정상적으로 연결되었습니다.\n"
f"요청자: {requested_by}\n\n"
f"알림 상태 확인하기: {notifications_url}\n"
)
return RenderedEmail(subject=subject, preheader=preheader, html=html_doc, text=text)
def _email_shell(*, kicker: str, title: str, preheader: str, body: str) -> str:
return f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{_h(title)}</title>
</head>
<body style="margin:0;background:#fbfaf8;font-family:Pretendard,-apple-system,BlinkMacSystemFont,'Apple SD Gothic Neo','Noto Sans KR',sans-serif;color:#1c2a2a;">
<span style="display:none!important;visibility:hidden;opacity:0;color:transparent;height:0;width:0;overflow:hidden;">{_h(preheader)}</span>
<div style="max-width:640px;margin:0 auto;padding:32px 20px;">
<div style="font-size:14px;font-weight:800;color:#3e7a6e;margin-bottom:16px;letter-spacing:0;">Vignette</div>
<section style="background:#ffffff;border:1px solid #e5e1da;border-radius:8px;padding:28px;box-shadow:0 1px 2px rgba(28,42,42,0.05);">
<p style="margin:0 0 8px;font-size:12px;font-weight:800;color:#5a6663;">{_h(kicker)}</p>
<h1 style="margin:0 0 14px;font-size:24px;line-height:1.35;color:#1c2a2a;font-weight:800;letter-spacing:0;">{_h(title)}</h1>
{body}
</section>
<p style="margin:16px 0 0;font-size:11px;line-height:1.6;color:#93a09c;">
메일은 Vignette 운영 알림입니다. 버튼이 열리지 않으면 링크를 복사해 브라우저 주소창에 붙여 넣어 주세요.
</p>
</div>
</body>
</html>"""
def _info_box(items: list[tuple[str, str]]) -> str:
rows = "".join(
f"""
<tr>
<td style="padding:5px 0;width:92px;color:#5a6663;font-size:13px;">{_h(label)}</td>
<td style="padding:5px 0;color:#1c2a2a;font-size:14px;font-weight:700;">{_h(value)}</td>
</tr>
"""
for label, value in items
if value
)
return f"""
<div style="background:#eef4f2;border-radius:8px;padding:14px 16px;margin-bottom:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;">
{rows}
</table>
</div>
"""
def _button(label: str, url: str) -> str:
safe_url = _h(url)
return f"""
<a href="{safe_url}"
style="display:inline-block;background:#3e7a6e;color:#fbfaf8;text-decoration:none;border-radius:8px;padding:12px 18px;font-size:14px;font-weight:800;">
{_h(label)}
</a>
"""
def _frontend_url(path: str) -> str:
base = settings.frontend_base_url.rstrip("/") or "http://localhost:5173"
suffix = "/" + path.lstrip("/")
return f"{base}{suffix}"
def _recipient_from_row(row: Any) -> NotificationRecipient:
return NotificationRecipient(
user_id=str(row["user_id"]) if row["user_id"] else None,
email=str(row["email"]),
display_name=str(row["display_name"] or row["email"]),
role=str(row["role"] or ""),
)
def _normalize_email(value: str) -> str:
return value.strip().lower()
def _role_label(value: str) -> str:
if value in {"teacher", "instructor"}:
return "교수자"
if value == "admin":
return "관리자"
return "학습자"
def _iso_text(value: Any) -> str:
if value is None:
return ""
iso = getattr(value, "isoformat", None)
if callable(iso):
return iso()
return str(value)
def _message_id_domain(email: str) -> str:
if "@" in email:
return email.rsplit("@", 1)[1]
return "vignette.local"
def _h(value: str) -> str:
return html.escape(value, quote=True)
class NotificationSkipped(RuntimeError):
"""Raised when a delivery is intentionally not sent."""

View file

@ -8,7 +8,7 @@ from contextlib import contextmanager
from typing import Any
from urllib.parse import parse_qs, urlencode, urlsplit
from fastapi import Response
from fastapi import HTTPException, Response
from starlette.requests import Request
from unittest.mock import AsyncMock, patch
@ -17,6 +17,7 @@ from .config import Settings, settings
from .deps import Principal, Role
from .routes import admin as admin_routes
from .routes import auth as auth_routes
from .routes import sessions as session_routes
from .saml import inflate_redirect_request
@ -355,6 +356,38 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
hoonjung_role = auth_routes._role_for_email("hoonjungkoo@hs.ac.kr")
self.assertEqual(hoonjung_role, Role.ADMIN)
async def test_pending_provider_user_enqueues_account_approval_notification(self) -> None:
with (
patched_settings(
environment="dev",
auth_new_user_default_status="pending",
auth_super_admin_emails=[],
auth_admin_emails=[],
auth_teacher_emails=[],
auth_approved_emails=[],
),
patch.object(auth_sessions, "get_pool", side_effect=RuntimeError("no db")),
patch.object(
auth_sessions.notifications,
"enqueue_account_pending_approval",
AsyncMock(),
) as enqueue_approval,
):
_, user = await auth_sessions.create_session(
email="new-approval@hs.ac.kr",
display_name="New Approval",
role="learner",
external_id="google:new-approval",
)
self.assertEqual(user.account_status, "pending")
enqueue_approval.assert_awaited_once_with(
user_id=user.user_id,
email="new-approval@hs.ac.kr",
display_name="New Approval",
role="learner",
)
async def test_super_admin_can_enter_teacher_and_learner_role_guards(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000601",
@ -373,6 +406,41 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(principal.can_access_role(Role.TEACHER))
self.assertTrue(principal.can_access_role(Role.ADMIN))
async def test_admin_role_can_enter_all_role_spaces_without_super_admin(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000602",
role=Role.ADMIN,
email="operator@twentyoz.kr",
display_name="Operator Admin",
admin_access=True,
super_admin=False,
)
teacher_checker = deps.require_role(Role.TEACHER)
teacher_view = await teacher_checker(principal)
learner_view = session_routes._ensure_learner(principal)
self.assertEqual(teacher_view.role, Role.TEACHER)
self.assertEqual(learner_view.role, Role.LEARNER)
self.assertTrue(principal.can_access_role(Role.LEARNER))
self.assertTrue(principal.can_access_role(Role.TEACHER))
self.assertTrue(principal.can_access_role(Role.ADMIN))
async def test_admin_access_flag_without_admin_role_does_not_enter_learner_space(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000603",
role=Role.TEACHER,
email="teacher-admin@hs.ac.kr",
display_name="Teacher Admin",
admin_access=True,
super_admin=False,
)
self.assertTrue(principal.can_access_role(Role.ADMIN))
self.assertFalse(principal.can_access_role(Role.LEARNER))
with self.assertRaises(HTTPException):
session_routes._ensure_learner(principal)
async def test_only_super_admin_can_grant_admin_access(self) -> None:
with (
patched_settings(

View file

@ -0,0 +1,186 @@
"""Notification email rendering and enqueue trigger tests."""
from __future__ import annotations
import unittest
from contextlib import contextmanager
from typing import Any
from unittest.mock import AsyncMock, patch
from .config import settings
from .routes import sessions
from .services import notifications, state_machine
from .services.persona import P1
from .store import InProcSession, TurnRecord
@contextmanager
def patched_settings(**values: Any):
previous = {key: getattr(settings, key) for key in values}
for key, value in values.items():
setattr(settings, key, value)
try:
yield
finally:
for key, value in previous.items():
setattr(settings, key, value)
def _ended_session() -> InProcSession:
state = state_machine.init_state(params=P1.openness_params())
state.stage = state_machine.Stage.EXPLORE
return InProcSession(
session_id="00000000-0000-0000-0000-00000000e111",
case_id="00000000-0000-0000-0000-00000000e111",
learner_id="00000000-0000-0000-0000-000000000111",
persona_code=P1.code,
theory_mode="humanistic",
persona=P1,
state=state,
session_no=1,
created_at=1_000.0,
ended_at=1_600.0,
ended=True,
turns=[
TurnRecord(
turn_seq=1,
speaker="counselor",
stage=state.stage.value,
text="상담자 발화",
text_masked="상담자 발화",
),
TurnRecord(
turn_seq=2,
speaker="client",
stage=state.stage.value,
text="내담자 응답",
text_masked="내담자 응답",
),
],
)
class NotificationEmailTemplateTest(unittest.TestCase):
def test_session_review_email_uses_polite_copy_and_direct_review_link(self) -> None:
rendered = notifications.render_email(
"session_review_ready",
{
"learner_label": "테스트 학습자",
"persona_name": "서연",
"session_no": 2,
"ended_at": "2026-06-29T09:00:00+09:00",
"review_url": "https://vignette.chanpaca.net/teach/session/s1/review",
"transcript": "메일에 들어가면 안 되는 축어록",
},
)
self.assertEqual(rendered.subject, "[Vignette] 검토할 회기가 있습니다")
self.assertIn("검토할 회기가 있습니다.", rendered.html)
self.assertIn("회기 검토하기", rendered.html)
self.assertIn("https://vignette.chanpaca.net/teach/session/s1/review", rendered.html)
self.assertIn("민감한 회기 내용은 메일에 포함하지 않았습니다", rendered.html)
self.assertNotIn("메일에 들어가면 안 되는 축어록", rendered.html)
def test_account_approval_email_links_admin_users(self) -> None:
rendered = notifications.render_email(
"account_pending_approval",
{
"display_name": "신규 사용자",
"email": "new@hs.ac.kr",
"role": "learner",
"approval_url": "https://vignette.chanpaca.net/admin/users",
},
)
self.assertEqual(rendered.subject, "[Vignette] 새 가입 승인 요청이 있습니다")
self.assertIn("새 가입 승인 요청이 있습니다.", rendered.html)
self.assertIn("가입 승인 확인하기", rendered.html)
self.assertIn("https://vignette.chanpaca.net/admin/users", rendered.html)
def test_admin_test_email_uses_distinct_test_copy(self) -> None:
rendered = notifications.render_email(
"admin_test_email",
{
"requested_by": "admin@hs.ac.kr",
"notifications_url": "https://vignette.chanpaca.net/admin",
},
)
self.assertEqual(rendered.subject, "[Vignette] 메일 알림 테스트입니다")
self.assertIn("메일 알림 테스트입니다.", rendered.html)
self.assertIn("발송 경로 확인용 테스트", rendered.html)
self.assertIn("알림 상태 확인하기", rendered.html)
self.assertIn("https://vignette.chanpaca.net/admin", rendered.html)
def test_smtp_sender_uses_html_text_parts_and_starttls(self) -> None:
rendered = notifications.render_email(
"session_review_ready",
{
"learner_label": "테스트 학습자",
"persona_name": "서연",
"session_no": 1,
"review_url": "https://vignette.chanpaca.net/teach/session/s1/review",
},
)
with (
patched_settings(
notification_email_provider="smtp",
smtp_host="smtp.example.test",
smtp_port=587,
smtp_username="mailer",
smtp_password="secret",
smtp_from_email="no-reply@example.test",
smtp_from_name="Vignette",
smtp_starttls=True,
smtp_ssl=False,
),
patch.object(notifications.smtplib, "SMTP") as smtp_class,
):
smtp = smtp_class.return_value.__enter__.return_value
provider_id = notifications._send_rendered_email(
recipient_email="teacher@hs.ac.kr",
recipient_name="교수자",
rendered=rendered,
)
self.assertIn("@example.test>", provider_id)
smtp_class.assert_called_once_with("smtp.example.test", 587, timeout=15)
smtp.starttls.assert_called_once()
smtp.login.assert_called_once_with("mailer", "secret")
smtp.send_message.assert_called_once()
message = smtp.send_message.call_args.args[0]
self.assertEqual(message["Subject"], "[Vignette] 검토할 회기가 있습니다")
self.assertIn("teacher@hs.ac.kr", message["To"])
self.assertTrue(message.is_multipart())
class NotificationTriggerTest(unittest.IsolatedAsyncioTestCase):
async def test_failed_session_evaluation_still_enqueues_teacher_review_notification(self) -> None:
sess = _ended_session()
with (
patch.object(
sessions.evaluator,
"evaluate_session",
AsyncMock(side_effect=RuntimeError("engine offline")),
),
patch.object(
sessions.session_persistence,
"save_session_evaluation",
AsyncMock(return_value=True),
) as save_evaluation,
patch.object(
sessions.notifications,
"enqueue_session_review_ready",
AsyncMock(),
) as enqueue_review,
):
await sessions._generate_and_save_session_evaluation(sess)
save_evaluation.assert_awaited_once()
enqueue_review.assert_awaited_once_with(session_id=sess.session_id)
if __name__ == "__main__":
unittest.main()

View file

@ -48,6 +48,66 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/notifications": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Notifications
* @description Return recent operational email delivery state.
*/
get: operations["list_notifications_admin_notifications_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/notifications/process": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Process Notifications
* @description Drain queued email notifications once from the admin console/API.
*/
post: operations["process_notifications_admin_notifications_process_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/notifications/test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Send Test Notification
* @description Queue and process one explicit admin email test.
*/
post: operations["send_test_notification_admin_notifications_test_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/tickets": {
parameters: {
query?: never;
@ -1339,6 +1399,85 @@ export interface components {
*/
status: "ok" | "degraded" | "down";
};
/** AdminNotificationDeliveryResponse */
AdminNotificationDeliveryResponse: {
/** Attempts */
attempts: number;
/** Created At */
created_at?: number | null;
/** Event Id */
event_id: string;
/** Id */
id: string;
/** Kind */
kind: string;
/** Last Error */
last_error?: string | null;
/** Provider Message Id */
provider_message_id?: string | null;
/** Recipient Email */
recipient_email: string;
/** Recipient Name */
recipient_name: string;
/** Recipient Role */
recipient_role: string;
/** Sent At */
sent_at?: number | null;
/**
* Status
* @enum {string}
*/
status: "queued" | "sending" | "sent" | "failed" | "skipped";
/** Subject */
subject: string;
/** Updated At */
updated_at?: number | null;
};
/** AdminNotificationProcessResponse */
AdminNotificationProcessResponse: {
/** Failed */
failed: number;
/** Processed */
processed: number;
/** Sent */
sent: number;
/** Skipped */
skipped: number;
};
/** AdminNotificationTestResponse */
AdminNotificationTestResponse: {
/** Failed */
failed: number;
/** Processed */
processed: number;
/** Provider */
provider: string;
/** Recipients */
recipients: number;
/** Sent */
sent: number;
/** Skipped */
skipped: number;
/** Smtp Configured */
smtp_configured: boolean;
};
/** AdminNotificationsResponse */
AdminNotificationsResponse: {
/** Deliveries */
deliveries: components["schemas"]["AdminNotificationDeliveryResponse"][];
/** Failed */
failed: number;
/** Provider */
provider: string;
/** Queued */
queued: number;
/** Sent */
sent: number;
/** Skipped */
skipped: number;
/** Smtp Configured */
smtp_configured: boolean;
};
/** AdminServiceHealth */
AdminServiceHealth: {
/** Detail */
@ -2486,6 +2625,11 @@ export interface components {
};
/** NotificationPreferences */
NotificationPreferences: {
/**
* Account Approval
* @default true
*/
account_approval: boolean;
/**
* Learner Progress
* @default false
@ -4185,6 +4329,106 @@ export interface operations {
};
};
};
list_notifications_admin_notifications_get: {
parameters: {
query?: {
limit?: number;
};
header?: never;
path?: never;
cookie?: {
"__Host-vignette_sid"?: string | null;
vignette_sid?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AdminNotificationsResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
process_notifications_admin_notifications_process_post: {
parameters: {
query?: {
limit?: number;
};
header?: never;
path?: never;
cookie?: {
"__Host-vignette_sid"?: string | null;
vignette_sid?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AdminNotificationProcessResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
send_test_notification_admin_notifications_test_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: {
"__Host-vignette_sid"?: string | null;
vignette_sid?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AdminNotificationTestResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_tickets_admin_tickets_get: {
parameters: {
query?: {

View file

@ -174,6 +174,7 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
session_done: true,
safety_signal: true,
learner_progress: true,
account_approval: true,
product_news: false,
};
@ -347,6 +348,12 @@ export default function Settings() {
hint: "교수자와 관리자에게만 표시됩니다.",
roles: ["teacher", "admin"],
},
{
id: "account_approval",
label: "가입 승인",
hint: "신규 가입 승인 요청이 있으면 알려줍니다.",
roles: ["admin"],
},
{
id: "product_news",
label: "제품 소식",