From 3bf38c50df374a5e811f4bf60c7f3217f0dd1ba3 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 29 Jun 2026 17:07:01 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 13 + apps/api/app/auth_sessions.py | 34 ++ apps/api/app/config.py | 29 + apps/api/app/db.py | 32 +- apps/api/app/main.py | 2 + apps/api/app/routes/admin.py | 157 +++++- apps/api/app/routes/sessions.py | 18 +- apps/api/app/routes/users.py | 3 +- apps/api/app/services/notifications.py | 744 +++++++++++++++++++++++++ apps/api/app/test_auth_providers.py | 70 ++- apps/api/app/test_notifications.py | 186 +++++++ apps/web/src/lib/api.gen.ts | 244 ++++++++ apps/web/src/pages/Settings.tsx | 7 + docs/dev_dashboard.html | 18 +- docs/guides/architecture.md | 50 +- docs/guides/local-development.md | 29 +- docs/ops/backlog-2026-06-26.md | 5 +- infra/.env.example | 11 + infra/db/init/05_runtime_auth.sql | 56 ++ infra/docker-compose.yml | 11 + scripts/check-deploy-preflight.py | 37 ++ scripts/run-notification-worker.py | 44 ++ 22 files changed, 1769 insertions(+), 31 deletions(-) create mode 100644 apps/api/app/services/notifications.py create mode 100644 apps/api/app/test_notifications.py create mode 100644 scripts/run-notification-worker.py diff --git a/.env.example b/.env.example index 40c4748..6ac85c6 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,19 @@ FRONTEND_BASE_URL=http://localhost:5173 FRONTEND_ORIGIN_MAP={"api-vignette.chanpaca.net":"https://vignette.chanpaca.net","api-vnet.18ka.net":"https://vnet.18ka.net"} CORS_ORIGINS=["https://vignette.chanpaca.net","https://vnet.18ka.net","https://vignette-b1q.pages.dev","http://localhost:5170","http://localhost:5171","http://localhost:5172","http://localhost:5173","http://localhost:5174","http://localhost:5175","http://localhost:5176","http://localhost:5177","http://localhost:5178","http://localhost:5179","http://localhost:5180","http://127.0.0.1:5170","http://127.0.0.1:5171","http://127.0.0.1:5172","http://127.0.0.1:5173","http://127.0.0.1:5174","http://127.0.0.1:5175","http://127.0.0.1:5176","http://127.0.0.1:5177","http://127.0.0.1:5178","http://127.0.0.1:5179","http://127.0.0.1:5180"] +# Operational email notifications. Use smtp only when SMTP credentials are ready. +NOTIFICATION_EMAIL_PROVIDER=disabled +NOTIFICATION_EMAIL_MAX_ATTEMPTS=3 +NOTIFICATION_EMAIL_RETRY_SECONDS=900 +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_EMAIL= +SMTP_FROM_NAME=Vignette +SMTP_STARTTLS=true +SMTP_SSL=false + # Live2D runtime. Models must be configured per persona via live2dModelUrl. VITE_LIVE2D_CUBISM_CORE=/live2d/live2dcubismcore.min.js diff --git a/apps/api/app/auth_sessions.py b/apps/api/app/auth_sessions.py index bfe587d..a4f792a 100644 --- a/apps/api/app/auth_sessions.py +++ b/apps/api/app/auth_sessions.py @@ -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, diff --git a/apps/api/app/config.py b/apps/api/app/config.py index b7e5b76..4c6e69c 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -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(): diff --git a/apps/api/app/db.py b/apps/api/app/db.py index 32e2860..2bb8f00 100644 --- a/apps/api/app/db.py +++ b/apps/api/app/db.py @@ -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 diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 79aae85..d77fc2f 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -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() diff --git a/apps/api/app/routes/admin.py b/apps/api/app/routes/admin.py index df280d5..9f4780c 100644 --- a/apps/api/app/routes/admin.py +++ b/apps/api/app/routes/admin.py @@ -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, diff --git a/apps/api/app/routes/sessions.py b/apps/api/app/routes/sessions.py index 4986e7c..13f5e54 100644 --- a/apps/api/app/routes/sessions.py +++ b/apps/api/app/routes/sessions.py @@ -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: diff --git a/apps/api/app/routes/users.py b/apps/api/app/routes/users.py index b0d1314..cd4fa26 100644 --- a/apps/api/app/routes/users.py +++ b/apps/api/app/routes/users.py @@ -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) diff --git a/apps/api/app/services/notifications.py b/apps/api/app/services/notifications.py new file mode 100644 index 0000000..33a32be --- /dev/null +++ b/apps/api/app/services/notifications.py @@ -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""" +

+ 승인 대기 중인 신규 사용자가 있습니다. 가입 승인 화면에서 수업 또는 연구 참여 범위를 확인한 뒤 승인 상태를 결정해 주세요. +

+ {_info_box([ + ("사용자", display_name), + ("이메일", email), + ("요청 역할", role), + ])} + {_button("가입 승인 확인하기", approval_url)} +

+ 계정 승인 전까지 해당 사용자는 Vignette 대기 화면만 볼 수 있습니다. +

+ """ + 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""" +

+ 종료된 학습 회기가 교수자 검토 대기 상태입니다. 회기 리뷰 화면에서 요약과 근거를 확인한 뒤 검토 상태를 남겨 주세요. +

+ {_info_box([ + ("학습자", learner_label), + ("내담자", persona_name), + ("회기", f"{session_no}회기" if session_no else "종료 회기"), + ("종료 시각", ended_at or "기록됨"), + ])} + {_button("회기 검토하기", review_url)} +

+ 민감한 회기 내용은 메일에 포함하지 않았습니다. 로그인 후 Vignette에서 확인해 주세요. +

+ """ + 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""" +

+ 관리자 메일 알림이 정상적으로 연결되었습니다. 이 메일은 실제 가입 승인이나 회기 검토 요청이 아니라 발송 경로 확인용 테스트입니다. +

+ {_info_box([ + ("요청자", requested_by), + ("용도", "운영 메일 발송 테스트"), + ])} + {_button("알림 상태 확인하기", notifications_url)} +

+ 이후 가입 승인 요청과 회기 검토 요청도 같은 메일 템플릿과 발송 큐를 사용합니다. +

+ """ + 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""" + + + + + {_h(title)} + + + {_h(preheader)} +
+
Vignette
+
+

{_h(kicker)}

+

{_h(title)}

+ {body} +
+

+ 이 메일은 Vignette 운영 알림입니다. 버튼이 열리지 않으면 링크를 복사해 브라우저 주소창에 붙여 넣어 주세요. +

+
+ +""" + + +def _info_box(items: list[tuple[str, str]]) -> str: + rows = "".join( + f""" + + {_h(label)} + {_h(value)} + + """ + for label, value in items + if value + ) + return f""" +
+ + {rows} +
+
+ """ + + +def _button(label: str, url: str) -> str: + safe_url = _h(url) + return f""" + + {_h(label)} + + """ + + +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.""" diff --git a/apps/api/app/test_auth_providers.py b/apps/api/app/test_auth_providers.py index 324b533..1db44ac 100644 --- a/apps/api/app/test_auth_providers.py +++ b/apps/api/app/test_auth_providers.py @@ -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( diff --git a/apps/api/app/test_notifications.py b/apps/api/app/test_notifications.py new file mode 100644 index 0000000..a10faaf --- /dev/null +++ b/apps/api/app/test_notifications.py @@ -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() diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts index 8b9e4d5..4ecbbc2 100644 --- a/apps/web/src/lib/api.gen.ts +++ b/apps/web/src/lib/api.gen.ts @@ -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?: { diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index 7dbf995..b5c7711 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -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: "제품 소식", diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index 69b9e7e..f278f8e 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -531,7 +531,8 @@

95차 적용(2026-06-29): refactor-governance P2/P3로 M2 one-shot session digest worker 경계를 추가했다. app/services/session_digest_worker.pyCompressionJob을 Node-compatible GenerateRequest/EngineMessage로 변환하고, 주입형 engine·audit_hook으로 단발 호출한 뒤 local quality gate를 통과한 결과만 session_summary.digest/compressed_by/token_countcase_profile.case_digest에 idempotent 적용할 수 있게 한다. DB loader는 persisted fallback summary와 client-visible masked transcript만 재구성하며 raw text, evaluator-only turn, CCD, end_state는 prompt에 넣지 않는다. 이는 provider live run, background scheduler, 임상 골든셋 품질평가, 재압축 완료가 아니다. 검증: py -3.11 -X utf8 -B -m pytest -p no:cacheprovider app/test_session_digest_worker.py app/test_session_memory.py -q 25 passed, py -3.11 -X utf8 -B -m pytest -p no:cacheprovider app/test_session_digest_worker.py app/test_session_memory.py app/test_session_turn_persistence.py app/test_orchestrator_masking.py app/test_runtime_policy.py -q 82 passed.

96차 적용(2026-06-29): M2 digest worker를 운영자가 명시 실행할 수 있도록 scripts/run-session-digest-worker.py dry-run/apply runner를 추가했다. 기본은 metadata-only dry-run이고, --apply를 줘야 accepted 후보를 DB에 반영한다. runner는 load → DB release → engine generate → short apply 순서로 동작해 LLM 호출 동안 DB transaction을 잡지 않는다. digest 본문은 민감할 수 있어 --show-digest를 명시할 때만 출력한다. 이는 실 provider 장시간 운영 완료가 아니다. 검증: py -3.11 -X utf8 -B -m py_compile scripts\run-session-digest-worker.py apps\api\app\services\session_digest_worker.py apps\api\app\test_session_digest_worker.py, py -3.11 -X utf8 scripts\run-session-digest-worker.py --help, M2 focused 25 passed.

97차 적용(2026-06-29): SESSION_DIGEST_WORKER_ENABLED=false 기본값을 두고, opt-in일 때만 세션 종료 성공 뒤 digest worker를 background task로 예약한다. scheduler는 persisted fallback row를 load한 뒤 DB connection을 놓고 engine을 호출하며, accepted plan만 짧은 apply transaction으로 반영한다. 이미 compressed_by가 있는 세션은 loader에서 제외하고, apply도 compressed_by IS NULL CAS로 보호하며, fallback summary 재작성 시 compression metadata를 초기화한다. 이는 bounded/default-off scheduler 골격이며, 실 provider 장시간 운영·임상 골든셋 품질평가·재압축 정책 완료가 아니다. 검증: py_compile, runner --help, M2 focused 30 passed.

-

최신 적용(99차, 2026-06-29): 대시보드 SSOT drift gate를 보강했다. scripts/check-dev-dashboard-ssot.py는 현재 카드 상태(done=25, planned=0, doing=0), CHECK 잔여 0, owner board counts(block=2, decide=7, ext=2), 외부 GATE 상세 6행, 결정 탭 7행, 자유연습 F-10 결정 문구, M2 30/87 검증 수치, default-off scheduler/CAS 문구, 실 provider·임상 골든셋·재압축 GATE 보존, stale 82/25/26 passed 및 낡은 결정 8건 문구 제거를 함께 확인한다. 검증: py_compile, py -3.11 -X utf8 scripts\check-dev-dashboard-ssot.py --json PASS. 아래 적용 노트 일부는 historical snapshot이라 당시 통과 수치를 그대로 보존한다.

+

99차 적용(2026-06-29): 대시보드 SSOT drift gate를 보강했다. scripts/check-dev-dashboard-ssot.py는 현재 카드 상태(done=25, planned=0, doing=0), CHECK 잔여 0, owner board counts(block=2, decide=7, ext=2), 외부 GATE 상세 6행, 결정 탭 7행, 자유연습 F-10 결정 문구, M2 30/87 검증 수치, default-off scheduler/CAS 문구, 실 provider·임상 골든셋·재압축 GATE 보존, stale 82/25/26 passed 및 낡은 결정 8건 문구 제거를 함께 확인한다. 검증: py_compile, py -3.11 -X utf8 scripts\check-dev-dashboard-ssot.py --json PASS. 아래 적용 노트 일부는 historical snapshot이라 당시 통과 수치를 그대로 보존한다.

+

100차 적용(2026-06-29): 관리자·교수자 메일링 시스템 1차를 추가했다. 신규 외부 로그인 pending 계정은 account_pending_approval:{user_id}, 회기 종료 후 평가/error record 저장 완료 세션은 session_review_ready:{session_id}, 명시 테스트 메일은 admin_test_email:{uuid} idempotency key로 app.notification_event/app.notification_delivery에 큐잉된다. 메일 HTML은 Vignette 톤앤매너(종이 배경, 세이지 CTA, 8px 카드)를 inline style로 유지하고, 본문에는 축어록·평가 전문을 넣지 않고 /admin/users, /teach/session/:sessionId/review, /admin 딥링크만 제공한다. NOTIFICATION_EMAIL_PROVIDER=smtpSMTP_*가 설정된 경우 실제 발송하며, GET /admin/notifications, POST /admin/notifications/process, POST /admin/notifications/test, scripts/run-notification-worker.py로 delivery 상태와 재시도를 운영한다.

20차 적용(2026-06-28): 신규 Google/SAML 사용자는 account_status=pending으로 시작하고 승인 전에는 /pending 안내 화면만 본다. yunchan@twentyoz.kr는 슈퍼 관리자 allowlist로 admin+approved를 받으며, /admin/users는 가입 승인 탭에서 pending 계정을 승인 또는 보류 처리한다.

21차 적용(2026-06-28): 관리자 페이지 진입권을 기본 역할과 분리해 app_user.admin_access로 저장한다. AUTH_SUPER_ADMIN_EMAILS 기본값은 yunchan@twentyoz.kr, hoonjungkoo@hs.ac.kr이며, 슈퍼 관리자는 학습자·교수자·관리자 공간 전환과 관리자 권한 부여/회수를 할 수 있다. 학생·교수 계정도 admin_access=true면 우측 상단 관리자 진입이 노출된다. 구성 슈퍼 관리자의 권한 회수와 계정 비활성화는 차단한다.

25차 적용(2026-06-28): /admin/users에서 허용 도메인 밖 이메일도 정확한 계정 단위로 강제 등록할 수 있다. Google/SAML/dev-login은 미리 등록된 이메일만 도메인 게이트 예외로 통과시키고, provider 로그인 시 기존 관리 row의 역할·코호트·승인 상태를 이어받는다. 미등록 외부 도메인 로그인은 계속 차단한다.

@@ -929,7 +930,8 @@ Engine session reuseengine_gateway.test_gateway_model27 tests OK; shared engine contract, JSON Schema + golden fixture validation, Node.js artifact conformance runner, GenerateResponse response validation, structured payload fallback parser, current-turn prompt split, direct evaluator/live-coach parser ownership, SSE frames/decoder, live session_id reuse, gateway-default 기본 라우팅 sentinel 정규화, missing-user 400 guard, ephemeral close fixed P1/H4 masking gateapp/test_pii_masking_eval.py app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py47 passed; phone/email/RRN 및 한국어 NAME/ORG raw 값이 generate/stream/evaluator payload와 client text_masked에 남지 않음. Optional ko recognizer fake span은 [NAME]/[ORG]로 마스킹되고 같은 문장의 phone은 후단 regex가 처리하며, adapter 실패 시에도 regex fallback이 유지된다. Synthetic ko fixture 15/15 pass, 자연 발화형 이름 라벨·자기소개와 negative control 포함, input/report schema validation, summary-only report(evidence_text_included=false), entity recall 1.0, forbidden substring removal 1.0, unexpected entity violations 0. P2a RBAC/audit/visibilityapp.test_rbac_idor8 tests OK; other learner 403, read_session audit, evaluator-only hidden, teacher session review read allowed while learner worksheet write remains 403 - Admin access delegationpython -m pytest app/test_auth_providers.py app/test_admin_ops.py -q / npm run typecheck / npm run check:api-types32 backend passed; admin_access and super_admin are exposed through auth/admin DTOs. Super admins can grant/revoke admin page access; configured super admins stay protected. Frontend role switching uses canAccessRole. + Admin access delegationpython -B -m pytest -p no:cacheprovider app/test_auth_providers.py app/test_runtime_policy.py app/test_rbac_idor.py app/test_teacher_dashboard.py app/test_learner_dashboard.py -q / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 / npm run check:api-types / npm run typecheck / npm run build70 backend focused passed + admin/auth/runtime 64 passed + admin E2E 8 passed. 실제 admin role은 학습자·교수자·관리자 공간을 모두 열고, admin_access만 받은 비관리자 계정은 관리자 콘솔 진입권만 얻는다. 관리자 sidebar는 운영 홈/사용자/권한/티켓과 교수자·학습자 작업 공간 링크를 함께 노출한다. + Session read-model DB readinesspython scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets / https://api-vignette.chanpaca.net/health2026-06-29 prod 503 원인은 운영 DB의 app.turns.provider_events 컬럼 누락이었다. 운영 DB hotfix 후 /teacher/dashboard code path는 source=database로 복구됐고, db.healthcheck(), runtime table readiness, deploy preflight DB mode가 app.turns.provider_eventsapp.session_review_status worksheet 컬럼을 함께 검증한다. Public health는 environment=prod, db=true, engine=true. Admin usage persistencepython -m pytest app/test_admin_ops.py app/test_runtime_policy.py -q / authenticated local public-API smoke26 passed; /admin/usage returns 200 with source=database, durable=true. Cloudflare blocked raw Python public smoke with 1010, so app-level HTTP was verified against 127.0.0.1:8001 using the same prod process. Synthetic health samplerrecord_admin_health_sample(), record-admin-health-sample.py, install-health-sampler-task.ps1Backend focused 31 passed. Python compile/help passed; PowerShell parser + -PrintOnly passed. Local one-shot appended 5 service rows to app.admin_health_event: status ok, engine_mode claude_cli. This remains sample history, not an SLA claim. Health retention/rollupadmin_health_daily_rollup, maintain-admin-health-events.py, /admin/uptimeBackend focused 36 passed. Python compile/help passed. Local dry-run returned rollup_event_count=0, prunable_event_count=0. Uptime summary now combines raw samples with daily rollups when raw rows have been pruned; detailed events stay raw-only. @@ -953,15 +955,15 @@ Python compilepython -m compileall app engine_gatewayPassed Phase 3 artifact gatespython scripts\check-phase3-artifacts.py --help + pytest app/test_phase3_artifact_checker.py -qPassed; checker now enforces CSV enums, KPI metric required fields/status, approved export PII/agreement/consent/withdrawal/file-hash gates. Actual pilot evidence still external. Web buildnpm run buildPassed - Pages production deploywrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true2026-06-29 manual deploy from committed source; preview https://eb2ed257.vignette-b1q.pages.dev - Custom domain assetshttps://vignette.chanpaca.net/login?deploy=202606292026-06-29 recheck: 200, index-9KEDuuBQ.js, index-DC83qEpU.css + Pages production deploywrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true2026-06-29 admin IA/readiness fix deploy from current working tree; preview https://c67ca04a.vignette-b1q.pages.dev + Custom domain assetshttps://vignette.chanpaca.net/login?admin-fix=20260629-12026-06-29 recheck: 200, index-Ba7CXeH_.js, index-DC83qEpU.css Legacy Live2D routes/live2d/mao/* / /live2d/haru/* / /live2d/live2dcubismcore.min.js404 Compose templatedocker compose -f infra\docker-compose.yml config --quietTemplate path is valid with dummy required env. API build context now uses repo root; stale rag.server sidecar removed. Docker image smokedocker build -f apps/api/Dockerfile . / docker run ... python -c "import app.main" / docker build -f apps/web/Dockerfile apps/webAPI/Web image build and API import smoke pass after packaging cleanup. Web build uses npm lockfile and ignores host node_modules; API image excludes local .env files. - Deploy preflightpython scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secretsPassed: exact-pinned API requirements, live coaching data/kb source pack, env template keys. DB mode can additionally check app-role DSN with --require-app-role. + Deploy preflightpython scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets / DB mode with local DATABASE_URLPassed: exact-pinned API requirements, live coaching data/kb source pack, env template keys, and DB readiness (current_user=vignette). DB mode can additionally check app-role DSN with --require-app-role and now verifies session read-model columns including app.turns.provider_events plus worksheet review columns. Fresh compose smokedocker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build + proxy /api/healthPassed with dummy production-safe env: API healthy, DB healthy, web/proxy up, http://localhost:18080/api/health 200 with db:true, engine:true, engine_mode:"claude_cli". Smoke volumes/network removed after run. Actual deployment envinfra/.env owner-secret fill-inRemaining external step: deployment target must provide real APP_DB_PASSWORD, OAuth client id/secret, OPENAI_API_KEY, SESSION_SECRET, and production-safe engine/voice flags. Current local stray values such as ENGINE_MODE=claude_p and prod sample TTS must not be copied. - Focused E2Eadmin + db-persistence + voice-success10 passed + Focused E2Eadmin + db-persistence + voice-successLatest admin spec: 8 passed on chromium desktop, including all-workspace admin navigation, health dashboard, real server-known users, operation tickets, mobile controls, and tablet form containment. Admin manage-usersadmin.spec.ts --grep manage real server-known usersdesktop/mobile 2 passed Full E2E baselinePLAYWRIGHT_PORT=5174 npm run e2e2026-06-27/28 기준선 113 passed Public auth discoveryE2E_PUBLIC_AUTH=1 npx playwright test --list --project=chromium-public-auth2 tests listed @@ -970,8 +972,8 @@ Persona auth boundaryGET /personasverified local/public unauth 401 Public loginauth.spec.ts --grep public login1 passed Public runtime scriptsstart/watch/install-public-runtime*.ps1parser OK; watchdog check-only healthy. Default public checks exclude not-yet-live api-vnet.18ka.net; add future domains explicitly with -AdditionalPublicHealthUrls. - Public API healthhttps://api-vignette.chanpaca.net/healthprod, db true, engine true - Public/local/Tailnet login recoveryhttps://vignette.chanpaca.net/login / https://api-vignette.chanpaca.net/health / https://alpaca-home.taile93291.ts.net/login2026-06-29 public recheck: public login 200 (assets/index-9KEDuuBQ.js, assets/index-DC83qEpU.css), public API health status=ok, environment=prod, db=true, engine=true, engine_mode=claude_cli, unauth public /personas 401. Earlier local/Tailnet checks remain recorded separately; vnet DNS A records are still 0. + Public API healthhttp://127.0.0.1:8001/health / https://api-vignette.chanpaca.net/health2026-06-29 after public API restart: both return status=ok, environment=prod, db=true, engine=true, engine_mode=claude_cli. Unauthenticated protected routes such as /personas and /teacher/dashboard return 401, not 503. + Public/local/Tailnet login recoveryhttps://vignette.chanpaca.net/login / https://api-vignette.chanpaca.net/health / https://alpaca-home.taile93291.ts.net/login2026-06-29 public recheck: public login 200 (assets/index-Ba7CXeH_.js, assets/index-DC83qEpU.css), public API health status=ok, environment=prod, db=true, engine=true, engine_mode=claude_cli, unauth public /personas 401. Earlier local/Tailnet checks remain recorded separately; vnet DNS A records are still 0. Local 5175 loginPLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 auth.spec.tsdesktop/mobile passed Learner/readiness E2Elearner.spec.ts + readiness.spec.ts desktop/mobile14 passed Learner screenshotslearn-empty-desktop.png / learn-empty-mobile-compact.pngdocument overflow 0, empty history visible diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index 3772466..b7d7706 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -89,6 +89,8 @@ vignette/ `GET /health`는 liveness + DB readiness(`db.healthcheck()`) + 엔진 게이트웨이 readiness (`engine_client.health_detail()`)를 합쳐 `{"status": "ok|degraded", db, engine, engine_mode, ...}`를 반환한다. +DB readiness는 auth/admin 테이블뿐 아니라 세션 read-model 핵심 테이블·컬럼 +(`app.sessions`, `app.turns.provider_events`, `app.session_review_status` worksheet 컬럼)을 함께 확인한다. ### 2.2 턴 오케스트레이터 — `app/services/orchestrator.py` @@ -378,7 +380,27 @@ session lifecycle을 유지하며, future Node read API는 이 read-model contra - `/teach/session/:sessionId/review` 화면은 같은 `GET /sessions/{id}/review` 자료를 교수자 읽기 전용으로 표시하고, 검토 메모 저장은 위 teacher endpoint로 분리한다. -### 2.9.2 공개 공유·검색 메타 — `app/routes/share.py` +### 2.9.2 운영 메일 알림 — `app/services/notifications.py` + +- 가입 승인 알림: Google/SAML 신규 사용자가 `account_status=pending`으로 세션을 만들면 + `account_pending_approval:{user_id}` idempotency key로 `app.notification_event`를 만들고, + 슈퍼 관리자·관리자 콘솔 접근권자 중 `account_approval` 알림을 켠 수신자에게 메일 delivery를 큐잉한다. +- 회기 검토 알림: 회기 종료 후 `app.session_evaluation` 저장이 완료되면 + `session_review_ready:{session_id}` idempotency key로 담당 코호트 교수자와 관리자에게 + `/teach/session/{sessionId}/review` 딥링크 메일을 큐잉한다. 평가 생성이 실패해도 error record가 저장되면 + 교수자 수동 검토가 필요하므로 알림은 생성된다. +- 메일은 업무 상태의 원본이 아니다. 승인 상태는 `app.app_user.account_status`, 교수자 검토 상태는 + `app.session_review_status`, 전송 상태는 `app.notification_delivery`가 각각 원본이다. +- SMTP 발송은 `NOTIFICATION_EMAIL_PROVIDER=smtp`와 `SMTP_*` env가 있을 때만 수행한다. provider가 + `disabled`면 이벤트/큐 구조는 유지되고 실제 발송은 worker 또는 관리자 처리 API 실행 시 skipped로 남는다. +- 메일 HTML은 Vignette 토큰 톤(종이 배경, 세이지-틸 CTA, 8px radius)을 inline style로 재현한다. 메일 본문에는 + 축어록, 평가 전문, 민감한 심리 상태를 넣지 않고, 로그인 후 앱 화면에서만 확인하게 한다. +- 운영 API: `GET /admin/notifications`는 최근 delivery와 queued/failed/sent/skipped 카운트를 반환하고, + `POST /admin/notifications/process` 또는 `scripts/run-notification-worker.py`는 큐를 한 번 drain한다. + `POST /admin/notifications/test`는 관리자 수신자에게 `admin_test_email:{uuid}` 테스트 이벤트를 만들고 + 같은 발송 큐로 즉시 처리한다. + +### 2.9.3 공개 공유·검색 메타 — `app/routes/share.py` - `GET /share/session/{token}` — 인증 없이 접근 가능한 unfurl HTML. Open Graph/Twitter Card/JSON-LD를 서버에서 직접 내려 URL만 전달해도 카카오톡·Slack·메일·AI 브라우저가 제목/요약/썸네일을 읽을 수 있게 한다. @@ -512,15 +534,15 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom). |---|---|---| | `/login` | Login (dev-login 경로 포함) | 공개 | | `/pending` | PendingApproval(승인 대기/보류 안내) | 인증됨, approved 전용 제한 화면 | -| `/learn` | LearnerHome(대시보드: 학습 요약, 최근 회기 리캡, AI 코치) | learner | -| `/learn/practice` | LearnerHome(연습 대상 선택·새 회기 시작) | learner | -| `/learn/history` | LearnerHome(회기 기록·보관/복원·리뷰 진입) | learner | -| `/learn/session/:sessionId` | Session(상담 화면) | learner | -| `/learn/session/:sessionId/review` | SessionReview(회기 리뷰) | learner | -| `/learn/avatar-expressions` | AvatarExpressionLab | learner | -| `/teach` | Professor(교수자 대시보드) | teacher | +| `/learn` | LearnerHome(대시보드: 학습 요약, 최근 회기 리캡, AI 코치) | learner/admin(learner 관점) | +| `/learn/practice` | LearnerHome(연습 대상 선택·새 회기 시작) | learner/admin(learner 관점) | +| `/learn/history` | LearnerHome(회기 기록·보관/복원·리뷰 진입) | learner/admin(learner 관점) | +| `/learn/session/:sessionId` | Session(상담 화면) | learner/admin(learner 관점) | +| `/learn/session/:sessionId/review` | SessionReview(회기 리뷰) | learner/admin(learner 관점) | +| `/learn/avatar-expressions` | AvatarExpressionLab | learner/admin(learner 관점) | +| `/teach` | Professor(교수자 대시보드) | teacher/admin | | `/teach/personas` | PersonaStudio(페르소나 저작·검수) | teacher/admin | -| `/teach/session/:sessionId/review` | SessionReview(교수자 읽기 전용 회기 검토) | teacher | +| `/teach/session/:sessionId/review` | SessionReview(교수자 읽기 전용 회기 검토) | teacher/admin | | `/admin` | Admin(운영 홈) | admin | | `/admin/users` | Admin(사용자 관리) | admin | | `/admin/access` | Admin(접근 권한) | admin | @@ -600,6 +622,10 @@ DB는 PostgreSQL 16 + pgvector(단일 SoR). 초기화 SQL은 `infra/db/init/`에 - **공개 공유 카드** `app.session_share_link` — `session_id` 단위 공개 토큰 해시와 sanitized preview payload. RLS는 학습자 본인 생성/폐기와 teacher/admin 열람, public route의 AI 컨텍스트 조회만 허용한다. 원문 축어록을 저장하지 않는다. +- **운영 메일 알림** `app.notification_event` / `app.notification_delivery` — 가입 승인 요청과 회기 검토 요청을 + 이벤트와 수신자별 delivery로 분리해 저장한다. `idempotency_key`가 중복 메일을 막고, delivery는 + `queued/sending/sent/failed/skipped` 상태와 시도 횟수, provider message id, 마지막 오류만 저장한다. + 메일 본문 HTML이나 회기 축어록은 DB에 복제하지 않는다. RLS는 관리자 전체 처리만 허용한다. - **운영 콘솔** `app.admin_health_event` / `app.admin_health_daily_rollup` — 관리자 `/admin/health` 조회 시점 또는 `scripts/record-admin-health-sample.py` synthetic sampler 실행 시점의 서비스별 원시 헬스 샘플은 `app.admin_health_event`에 남긴다. `scripts/maintain-admin-health-events.py`는 명시 @@ -668,8 +694,10 @@ DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`): 서버 로그에 남긴다. 프론트는 `token_exchange_failed`, `invalid_state`, provider error(`access_denied`/`provider_error`), identity claim 실패를 구분하고 실패 reason code를 화면에 함께 표시한다. - 역할은 `AUTH_TEACHER_EMAILS`/`AUTH_ADMIN_EMAILS` email allowlist로 1차 판정한다. - `admin_access`는 기본 역할과 별도인 관리자 콘솔 진입 권한이며, 슈퍼 관리자만 `/admin/users`에서 - 부여·회수할 수 있다. `AUTH_SUPER_ADMIN_EMAILS`는 항상 관리자 콘솔 접근, 학습자·교수자 공간 접근, + 실제 `admin` 역할 사용자는 관리자 콘솔, 교수자 공간, 학습자 공간에 모두 접근할 수 있다. + `admin_access`는 기본 역할과 별도인 관리자 콘솔 진입 권한이며, 비관리자 계정에 학습자·교수자 + 공간 접근권을 추가하지 않는다. 슈퍼 관리자만 `/admin/users`에서 `admin_access`를 부여·회수할 수 있다. + `AUTH_SUPER_ADMIN_EMAILS`는 항상 관리자 콘솔 접근, 학습자·교수자 공간 접근, approved 상태를 부여하는 신뢰 루트다(기본 `yunchan@twentyoz.kr`, `hoonjungkoo@hs.ac.kr`). 코호트는 `AUTH_EMAIL_COHORT_MAP`과 `AUTH_DOMAIN_COHORT_MAP` 설정, SAML fixture의 `cohort` claim을 합쳐 `cohort_ids`로 세션에 저장한다. 관리 사용자 `app_user.external_id`는 provider subject 기반 diff --git a/docs/guides/local-development.md b/docs/guides/local-development.md index 977dd7c..92c5e31 100644 --- a/docs/guides/local-development.md +++ b/docs/guides/local-development.md @@ -142,6 +142,8 @@ npm install | `EVALUATOR_SEMANTIC_CACHE_ENABLED` | `true` | fast/deep evaluator structured 결과 인메모리 캐시 활성화. 원문 prompt/completion은 저장하지 않음 | | `EVALUATOR_SEMANTIC_CACHE_TTL_SECONDS` | `900` | evaluator cache TTL(초). 0 이하면 비활성 | | `EVALUATOR_SEMANTIC_CACHE_MAX_ENTRIES` | `256` | evaluator cache LRU 최대 엔트리 수. 0 이하면 비활성 | +| `NOTIFICATION_EMAIL_PROVIDER` | `disabled` | 운영 메일 provider. 실제 발송은 `smtp`와 `SMTP_*` 설정이 있을 때만 수행 | +| `SMTP_HOST` / `SMTP_FROM_EMAIL` | 빈 값 | `NOTIFICATION_EMAIL_PROVIDER=smtp`일 때 필요한 SMTP 호스트와 발신 주소 | > 참고: 프로세스 환경변수(`$env:KEY`)는 `.env`보다 우선한다. 일회성 오버라이드에 쓸 수 있다. @@ -266,6 +268,8 @@ C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe scripts\mainta - dev-login 사용자는 로컬/E2E 흐름 유지를 위해 `account_status=approved`로 생성된다. - Google/SAML 신규 사용자는 기본적으로 `account_status=pending`이며, 승인 전에는 `/pending` 화면만 볼 수 있다. 관리자 콘솔 `/admin/users`의 가입 승인 탭에서 `approved`로 바꾸면 역할 홈에 접근한다. + DB가 연결되어 있으면 pending 생성 시 `app.notification_event`/`app.notification_delivery`에 가입 승인 + 메일 큐가 생긴다. `NOTIFICATION_EMAIL_PROVIDER=disabled`인 로컬 기본값에서는 실제 메일은 발송하지 않는다. - 신규 사용자 또는 온보딩 미완료 사용자는 로그인 직후 `/onboarding`에서 닉네임, 자기소개, 선택 아바타 이미지, 이름, 소속, 학과, 학년/직위, 전화번호, 주소/수령지와 약관·개인정보 동의를 저장해야 역할 홈으로 이동한다. 학습자 회기 시작은 온보딩 완료와 동의가 모두 있어야 한다. @@ -277,7 +281,24 @@ C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe scripts\mainta > 단, 관리자가 `/admin/users`에 미리 등록한 정확한 이메일은 도메인 밖이어도 로그인할 수 있다. > 미등록 외부 도메인은 계속 `403 email domain is not allowed`. -### 3.1 PowerShell(권장) — Invoke-RestMethod + 세션 쿠키 +### 3.1 메일 알림 큐 확인/처리 + +가입 승인과 교수자 회기 검토 알림은 상태 원본이 아니라 보조 알림이다. 원본 상태는 각각 +`app.app_user.account_status`, `app.session_review_status`이고, 메일 전송 상태만 +`app.notification_delivery`에 남는다. + +```powershell +cd D:\workspace\vignette +# SMTP 설정이 준비된 환경에서 큐를 한 번 처리 +python scripts\run-notification-worker.py --limit 25 +``` + +관리자 API에서도 `GET /admin/notifications`로 최근 delivery를 보고, +`POST /admin/notifications/process`로 한 번 처리할 수 있다. `POST /admin/notifications/test`는 관리자 +수신자에게 테스트 메일 이벤트를 만들고 같은 큐로 즉시 처리한다. 메일 본문에는 축어록이나 평가 전문을 넣지 않고, +`/admin/users`, `/teach/session/:sessionId/review`, `/admin` 링크만 제공한다. + +### 3.2 PowerShell(권장) — Invoke-RestMethod + 세션 쿠키 PowerShell의 `curl`은 `Invoke-WebRequest` 별칭이라 JSON 본문·쿠키 다루기가 번거롭다. PowerShell에서는 `Invoke-RestMethod`가 가장 깔끔하다. @@ -291,7 +312,7 @@ Invoke-RestMethod -Method Post -Uri http://127.0.0.1:8000/auth/dev-login ` Invoke-RestMethod -Uri http://127.0.0.1:8000/auth/me -WebSession $s ``` -### 3.2 curl.exe(셸 무관) — JSON 본문은 파일로 +### 3.3 curl.exe(셸 무관) — JSON 본문은 파일로 Windows에서 따옴표 이스케이프 사고를 피하려면 본문을 파일에 넣고 `--data-binary @file`로 보낸다. (PowerShell에서는 반드시 `curl.exe`라고 적어 별칭이 아닌 실제 curl을 호출한다.) @@ -309,7 +330,7 @@ curl.exe -i -X POST http://127.0.0.1:8000/auth/dev-login ` curl.exe http://127.0.0.1:8000/auth/me -b cookies.txt ``` -### 3.3 웹 UI +### 3.4 웹 UI `apps/web`의 로그인 화면(`Login.tsx`)에 dev-login 경로가 있다. 웹을 띄운 상태(5173)에서 프록시를 통해 `/api/auth/dev-login`으로 동일하게 동작한다. @@ -317,7 +338,7 @@ curl.exe http://127.0.0.1:8000/auth/me -b cookies.txt > 로컬/Tailnet 테스트는 dev-login을 사용한다. Google OAuth는 공개 도메인 > `https://vignette.chanpaca.net`에서만 실제 계정 흐름으로 검증한다. -### 3.4 라이브 코칭 source pack RAG 색인 +### 3.5 라이브 코칭 source pack RAG 색인 `data/kb/live_coaching_workbook_0615.json`와 `data/kb/live_coaching_sources/*.json`는 라이브 코칭의 기본 근거 source pack이다. API가 DB와 연결된 상태라면 관리자 dev-login 쿠키로 같은 자료를 RAG KB에도 diff --git a/docs/ops/backlog-2026-06-26.md b/docs/ops/backlog-2026-06-26.md index d776fd5..6e1004d 100644 --- a/docs/ops/backlog-2026-06-26.md +++ b/docs/ops/backlog-2026-06-26.md @@ -6,6 +6,8 @@ 최신 동기화(2026-06-29): M2 다회기 케이스 아크는 `TurnMemory` 값 객체로 턴 메모리 전달 경계를 줄였고, `DigestQualityAssessment`/`SessionDigestWorkerOutcome`로 LLM digest 후보 local quality harness를 추가했다. 이번 라운드에서는 `session_digest_worker.py`가 `CompressionJob`→Node-compatible `GenerateRequest` 변환, 주입형 engine/audit 호출, accepted-only `session_summary`/`case_profile` 적용 계획과 one-shot DB loader 경계를 소유한다. `scripts/run-session-digest-worker.py`는 metadata-only dry-run/apply runner를 제공하고, API는 `SESSION_DIGEST_WORKER_ENABLED=false` 기본값에서 opt-in일 때만 세션 종료 뒤 background worker를 예약한다. loader/apply는 `compressed_by IS NULL` CAS로 이미 압축된 세션 재실행 race를 막고, engine 호출은 DB transaction 밖에서 수행한다. loader는 persisted fallback summary와 client-visible `text_masked` transcript만 재구성하며 raw `text`, evaluator-only turn, CCD, end_state를 압축 prompt에 넣지 않는다. `scripts/check-dev-dashboard-ssot.py`는 대시보드 카드 상태와 M2 30/87 검증 수치, stale DONE/GATE 문구를 guard한다. `recall_summary`/`pinned_facts`/`recent_turns`/`kb_behavior_cues`는 `TurnContext.memory`에 보관되고, `prepare_turn(memory=...)`에서 마스킹된 뒤 `build_turn_messages(memory=...)`로 전달된다. auth managed-user upsert 입력도 `ManagedUserUpsertInput`으로 단일화했지만, 이는 내부 호출 경계 정리이며 외부 API·온보딩 정책·권한 모델 변경은 아니다. Python engine gateway의 `provider/model` 응답 메타 규칙은 helper로 모았고, `GatewayPromptParts`가 current-turn `system_prompt`/`user_payload` 분해 경계를 소유한다. Node conformance runner는 `gateway-default`가 request default-routing sentinel이며 response/done model은 resolved provider model이어야 함을 검증한다. 검증은 `py_compile`, runner `--help`, dashboard SSOT checker PASS, M2 digest worker + memory focused 30 passed, M2 주변 회귀 87 passed, M2 local harness 20 passed, M2/voice/evaluation focused 115 passed, auth/admin/session focused 103 passed, gateway contract 27 passed, backend focused 120 passed, Node conformance OK, `npm run check:api-types`. 단, 실 provider 장시간 운영·임상 골든셋 품질평가·재압축은 계속 후속 GATE다. +최신 동기화 추가(2026-06-29): 관리자·교수자 메일링 시스템 1차가 추가됐다. 가입 승인 요청과 회기 검토 요청은 `app.notification_event`/`app.notification_delivery`에 idempotent 큐로 남기고, SMTP 설정이 있을 때만 실제 메일을 발송한다. 메일 본문은 Vignette 톤앤매너를 따르되 축어록·평가 전문을 포함하지 않고 `/admin/users`, `/teach/session/:sessionId/review` 딥링크만 제공한다. + 분류: **B1 비차단 폴리시** · **B2 환경 제약(증거 생산 불가)** · **B3 소유자 결정** · **B4 외부 거버넌스** > **B0. 원천문서 갭 분석 (2026-06-26 추가)** — 한신대 산학협력 원천문서 5종 정독으로 도출한 "부족한 부분"(critical 3 / high 4 / medium+ 6)은 **SSOT 대시보드** `docs/dev_dashboard.html` "원천문서 갭 분석" 섹션과 상세 `docs/ops/source-docs-gap-analysis-2026-06-26.md`에서 추적한다. C1 사례개념화 산출물은 저장형 워크시트, 외부 루브릭 scaffold, 교수자 수동 검수 상태 저장까지 4차 구조를 만들었고, C2 위기개입 프로토콜은 1차 구조, C3 이론모드는 2차 명시 선택 UI까지 만들었다. 콘텐츠 정의는 임상팀(구훈정·어유경) 소유라 코드는 구조를 선제 구축하되 임상 문안과 평가기준은 외부 정의로 받는다. @@ -19,7 +21,8 @@ - [x] **P1 세션 아바타 PSD v2 정렬** — (2026-06-27 처리) 컨셉 보드 크롭 `p1-concept`를 기본값에서 내리고, 사용자 제공 `라투디 여캐_ver2.psd`에서 추출한 `seoyeon-live2d-psd-v2`를 P1 기본값으로 연결했다. `sad` 표정은 PSD의 울상 눈썹, 우는 입, 눈물 파츠를 별도로 합성하고, 데스크톱 스테이지는 3행 구조를 유지해 현재 상태 배지가 아바타와 겹치지 않게 했다. **검증: PSD 파츠 추출 53개, `rasterArtSet="seoyeon-live2d-psd-v2"`, sad 프리뷰에서 눈물/우는 입/울상 눈썹 확인.** - [x] **SEO/GEO 기본 신호 + 회기 리뷰 URL 공유 카드** — (2026-06-27 처리) `index.html`에 canonical/description/Open Graph/Twitter Card/JSON-LD를 추가하고, `robots.txt`/`sitemap.xml`/`llms.txt`를 게시했다. 종료된 학습자 회기는 `POST /sessions/{id}/share`가 공개 토큰을 생성하고 `GET /share/session/{token}`이 서버 HTML로 요약·썸네일·JSON-LD를 내려 URL unfurl을 지원한다. `app.session_share_link`에는 토큰 해시와 sanitized preview payload만 저장하며 원문 축어록·학습자 식별자는 포함하지 않는다. 공유 페이지는 교수자 전달용이라 `noindex`를 유지한다. **검증: `pytest app/test_session_share.py app/test_session_turn_persistence.py -q` 21 passed, `python -m pytest app/ -q` 178 passed, `python -m pytest engine_gateway/ -q` 11 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `PLAYWRIGHT_PORT=5174 npm run e2e` 113 passed.** 배포 후 실제 카카오톡/Slack/메일 unfurl 1회 확인 권장. - [x] **학습자·교수자·운영·페르소나 스튜디오 레이아웃 정렬** — (2026-06-28 처리) 작업형 화면에만 `AppShell wide` 폭 정책을 적용하고, 학습자 홈 회기 목록은 큐형 행으로 정리했다. 교수 콘솔은 검토 대기·위기 알림·페르소나 검수·페르소나 저작실 순서가 DOM과 화면 모두 일치하며, 운영 콘솔 사용자/티켓은 조밀한 운영 행으로 낮췄다. 페르소나 스튜디오는 SSOT 첨부/RAG 생성/항목 편집/검증 큐를 한 작업면에서 유지한다. 리포트와 대표 캡처: `docs/ops/layout-research-2026-06-28/dashboard-layout-alignment-report.md`, `docs/ops/layout-research-2026-06-28/final-gate-*.png`. **검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --workers=1` 7 passed, `npx playwright test e2e/session-layout.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1` 8 passed, `npx playwright test e2e/learner.spec.ts e2e/teacher.spec.ts e2e/admin.spec.ts --project=chromium-desktop --workers=1` 17 passed.** -- [x] **관리자 권한 위임 1차** — (2026-06-28 처리) 역할(role)과 관리자 페이지 진입권(`app_user.admin_access`)을 분리했다. `AUTH_SUPER_ADMIN_EMAILS` 기본값은 `yunchan@twentyoz.kr`, `hoonjungkoo@hs.ac.kr`이며 슈퍼 관리자만 `/admin/users`에서 관리자 권한을 부여·회수한다. 구성 슈퍼 관리자는 학습자·교수자·관리자 라우트 전환이 가능하고, 일반 학생/교수 계정도 `admin_access=true`면 우측 상단 관리자 진입이 보인다. **검증: `python -m pytest app/test_auth_providers.py app/test_admin_ops.py -q` 32 passed, `npm run check:api-types`, `npm run typecheck`.** +- [x] **관리자 권한 위임 1차** — (2026-06-28 처리, 2026-06-29 보강) 역할(role)과 관리자 페이지 진입권(`app_user.admin_access`)을 분리했다. `AUTH_SUPER_ADMIN_EMAILS` 기본값은 `yunchan@twentyoz.kr`, `hoonjungkoo@hs.ac.kr`이며 슈퍼 관리자만 `/admin/users`에서 관리자 권한을 부여·회수한다. 실제 `admin` 역할 사용자는 학습자·교수자·관리자 라우트 전환이 가능하고, `/admin` 왼쪽 메뉴도 운영 홈/사용자/권한/티켓과 교수자·학습자 작업 공간을 함께 노출한다. 일반 학생/교수 계정은 `admin_access=true`여도 관리자 콘솔 진입권만 얻고 학습자·교수자 역할권은 추가되지 않는다. **검증: `python -B -m pytest -p no:cacheprovider app/test_auth_providers.py app/test_runtime_policy.py app/test_rbac_idor.py app/test_teacher_dashboard.py app/test_learner_dashboard.py -q` 70 passed, `python -B -m pytest -p no:cacheprovider app/test_auth_providers.py app/test_admin_ops.py app/test_runtime_policy.py -q` 64 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1` 8 passed.** +- [x] **관리자·교수자 메일링 시스템 1차** — (2026-06-29 처리) 신규 외부 로그인 사용자가 `account_status=pending`이면 관리자 승인 메일 큐를 만들고, 회기 종료 후 평가 또는 error record가 저장되면 담당 교수자/관리자에게 회기 검토 메일 큐를 만든다. `app.notification_event`는 `account_pending_approval:{user_id}`, `session_review_ready:{session_id}`, `admin_test_email:{uuid}` idempotency key로 중복 발송을 막고, `app.notification_delivery`는 수신자별 `queued/sending/sent/failed/skipped` 상태와 재시도 정보를 보관한다. SMTP 설정은 `NOTIFICATION_EMAIL_PROVIDER=smtp`, `SMTP_*` env로 주입하며, 관리자 API `GET /admin/notifications`, `POST /admin/notifications/process`, `POST /admin/notifications/test`와 `scripts/run-notification-worker.py`로 큐를 확인·처리한다. HTML 메일은 Vignette 종이 배경/세이지 CTA 톤을 inline style로 유지하고, 본문에는 축어록·평가 전문을 넣지 않는다. **검증: `python -m compileall apps\api\app\services\notifications.py apps\api\app\routes\admin.py apps\api\app\routes\sessions.py apps\api\app\auth_sessions.py apps\api\app\routes\users.py`, `python -B -m pytest -p no:cacheprovider app/test_notifications.py app/test_auth_providers.py app/test_admin_ops.py app/test_runtime_policy.py -q` 69 passed, `npm run generate:api-types`, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `python scripts\check-deploy-preflight.py --env-file infra\.env.example --allow-placeholder-secrets --skip-db`, `python scripts\run-notification-worker.py --help`, `python -X utf8 scripts\check-dev-dashboard-ssot.py --json` PASS.** - [x] **회기 아카이브 저장/복원 API** — (2026-06-28 처리) `/learn/history`의 `보관됨`을 실제 학습자별 보기 상태로 연결했다. 종료 회기는 `POST /sessions/{id}/archive`/`restore`로 보관·복원하고, `app.session_archive_state`는 삭제가 아니라 `archived_at`/`updated_at`만 저장한다. `LearnerSessionSummary.archived`, `SessionArchiveResponse`, `LearnerDashboardOverview.archived_sessions`를 OpenAPI에 고정했고, 보관된 회기는 리뷰 대기 행동 큐에서 빠진다. 회기·턴·리뷰·공유 링크·연구/감사 증거는 삭제하지 않는다. **검증: `python -B -m pytest app/ -q` 178 passed, `python -B -m pytest engine_gateway/ -q` 11 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `npx playwright test e2e/learner.spec.ts --project=chromium-desktop --workers=1` 6 passed.** - [x] **OpenAI TTS voice preset DB map 연결** — (2026-06-28 처리) `app.persona_voice_map`의 OpenAI row를 `/voice/ws` TTS voice 선택에 연결했다. 명시 query preset은 DB map보다 우선하고, 세션 바인딩은 `app.sessions.persona_id/persona_version`으로 voice map을 찾으며, dev persona 생성은 catalog persona id/version map을 사용한다. seed materializer는 기본 OpenAI voice map을 `ON CONFLICT DO NOTHING`으로 생성하고, dev 런타임 스키마 보강은 기존 DB의 `persona_voice_map` 누락도 복구한다. OpenAI가 아닌 provider row는 기존 persona-code fallback으로 안전하게 흡수한다. **검증: `pytest app/test_runtime_policy.py app/test_persona_review.py app/test_voice_service.py app/test_voice_ws.py -q` 75 passed, `pytest app/ -q` 178 passed, `pytest engine_gateway/ -q` 11 passed.** - [x] **빈상태 컬럼 높이 여백** — (2026-06-28 처리) session-review 0건 상태는 1280px 이상에서 3컬럼 masonry를 쓰지 않고 빈 리뷰 전용 2컬럼 순차 레이아웃으로 전환한다. 가짜 기록·장식 콘텐츠를 넣지 않고, `EMPTY_REVIEW_SESSION_ID` fixture와 `session-review-empty` visual gate로 390/720/861/900/1024/1280/1440 전 폭을 검증한다. session-prestart와 learner-home은 기존 gate 대상에 남겨 함께 무회귀 확인했다. **검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --workers=1` 9 passed, `npx playwright test e2e/session-layout.spec.ts e2e/session-review.spec.ts --project=chromium-desktop --workers=1` 6 passed.** diff --git a/infra/.env.example b/infra/.env.example index d09216f..2269d10 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -27,6 +27,17 @@ FRONTEND_BASE_URL=https://vignette.chanpaca.net FRONTEND_ORIGIN_MAP={"api-vignette.chanpaca.net":"https://vignette.chanpaca.net","api-vnet.18ka.net":"https://vnet.18ka.net"} CORS_ORIGINS=["https://vignette.chanpaca.net","https://vnet.18ka.net","https://vignette-b1q.pages.dev","http://localhost:5170","http://localhost:5171","http://localhost:5172","http://localhost:5173","http://localhost:5174","http://localhost:5175","http://localhost:5176","http://localhost:5177","http://localhost:5178","http://localhost:5179","http://localhost:5180","http://127.0.0.1:5170","http://127.0.0.1:5171","http://127.0.0.1:5172","http://127.0.0.1:5173","http://127.0.0.1:5174","http://127.0.0.1:5175","http://127.0.0.1:5176","http://127.0.0.1:5177","http://127.0.0.1:5178","http://127.0.0.1:5179","http://127.0.0.1:5180"] # For local-only compose development, set ENVIRONMENT=dev and add http://localhost:5173 to CORS_ORIGINS. +NOTIFICATION_EMAIL_PROVIDER=disabled +NOTIFICATION_EMAIL_MAX_ATTEMPTS=3 +NOTIFICATION_EMAIL_RETRY_SECONDS=900 +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_EMAIL= +SMTP_FROM_NAME=Vignette +SMTP_STARTTLS=true +SMTP_SSL=false OPENAI_API_KEY= OPENAI_BASE_URL=https://api.openai.com/v1 VIGNETTE_VOICE_POC_SAMPLE_TTS=false diff --git a/infra/db/init/05_runtime_auth.sql b/infra/db/init/05_runtime_auth.sql index e6defe7..0c1e6c1 100644 --- a/infra/db/init/05_runtime_auth.sql +++ b/infra/db/init/05_runtime_auth.sql @@ -161,6 +161,49 @@ CREATE INDEX IF NOT EXISTS idx_support_ticket_parent ON app.support_ticket(parent_ticket_id) WHERE parent_ticket_id IS NOT NULL; +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() +); + +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) +); + +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); + CREATE TABLE IF NOT EXISTS app.learner_prepost_measure ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE CASCADE, @@ -241,6 +284,19 @@ CREATE POLICY p_support_ticket_delete ON app.support_ticket FOR DELETE USING (app.current_role_name() = 'admin'); +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'); + ALTER TABLE app.learner_prepost_measure ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_learner_prepost_measure_select ON app.learner_prepost_measure; DROP POLICY IF EXISTS p_learner_prepost_measure_insert ON app.learner_prepost_measure; diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 9decadf..67c44fd 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -54,6 +54,17 @@ services: FRONTEND_ORIGIN_MAP: '${FRONTEND_ORIGIN_MAP:-{"api-vignette.chanpaca.net":"https://vignette.chanpaca.net","api-vnet.18ka.net":"https://vnet.18ka.net"}}' CORS_ORIGINS: '${CORS_ORIGINS:-["https://vignette.chanpaca.net","https://vnet.18ka.net","https://vignette-b1q.pages.dev"]}' USER_UPLOAD_DIR: ${USER_UPLOAD_DIR:-/app/uploads} + NOTIFICATION_EMAIL_PROVIDER: ${NOTIFICATION_EMAIL_PROVIDER:-disabled} + NOTIFICATION_EMAIL_MAX_ATTEMPTS: ${NOTIFICATION_EMAIL_MAX_ATTEMPTS:-3} + NOTIFICATION_EMAIL_RETRY_SECONDS: ${NOTIFICATION_EMAIL_RETRY_SECONDS:-900} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USERNAME: ${SMTP_USERNAME:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-} + SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Vignette} + SMTP_STARTTLS: ${SMTP_STARTTLS:-true} + SMTP_SSL: ${SMTP_SSL:-false} extra_hosts: - "host.docker.internal:host-gateway" volumes: diff --git a/scripts/check-deploy-preflight.py b/scripts/check-deploy-preflight.py index 8911e53..48fac4c 100644 --- a/scripts/check-deploy-preflight.py +++ b/scripts/check-deploy-preflight.py @@ -207,12 +207,41 @@ async def check_database(database_url: str, *, require_app_role: bool) -> Check: to_regclass('app.session_evaluation') IS NOT NULL AS has_session_evaluation, to_regclass('app.live_coach_events') IS NOT NULL AS has_live_coach_events, to_regclass('app.session_share_link') IS NOT NULL AS has_session_share_link, + 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 = 'persona_card' AND column_name = 'triggers' ) AS has_persona_triggers, + 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, + EXISTS ( + SELECT 1 FROM pg_indexes + WHERE schemaname = 'app' + AND tablename = 'notification_delivery' + AND indexname = 'idx_notification_delivery_queue' + ) AS has_notification_delivery_queue_index, EXISTS ( SELECT 1 FROM pg_policies WHERE schemaname = 'app' @@ -245,7 +274,15 @@ async def check_database(database_url: str, *, require_app_role: bool) -> Check: "has_session_evaluation", "has_live_coach_events", "has_session_share_link", + "has_notification_event", + "has_notification_delivery", + "has_sessions", + "has_turns", + "has_session_review_status", "has_persona_triggers", + "has_turn_provider_events", + "has_session_review_worksheet_columns", + "has_notification_delivery_queue_index", "has_session_write_policies", ) if not row[key] diff --git a/scripts/run-notification-worker.py b/scripts/run-notification-worker.py new file mode 100644 index 0000000..2247a42 --- /dev/null +++ b/scripts/run-notification-worker.py @@ -0,0 +1,44 @@ +"""Drain queued Vignette email notifications once. + +Use from Task Scheduler, cron, or a one-shot ops shell after SMTP env is loaded. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +API_ROOT = ROOT / "apps" / "api" +if str(API_ROOT) not in sys.path: + sys.path.insert(0, str(API_ROOT)) + + +async def main_async(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--limit", type=int, default=25, help="Maximum deliveries to process.") + args = parser.parse_args(argv) + + from app.db import close_pool, init_pool + from app.services.notifications import ensure_notification_tables, process_queued_email_notifications + + await init_pool() + try: + await ensure_notification_tables() + result = await process_queued_email_notifications(limit=args.limit) + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 1 if result.get("failed", 0) else 0 + finally: + await close_pool() + + +def main() -> int: + return asyncio.run(main_async(sys.argv[1:])) + + +if __name__ == "__main__": + raise SystemExit(main())