메일 알림 시스템 추가
This commit is contained in:
parent
ddf12a851c
commit
3bf38c50df
22 changed files with 1769 additions and 31 deletions
13
.env.example
13
.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"}
|
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"]
|
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.
|
# Live2D runtime. Models must be configured per persona via live2dModelUrl.
|
||||||
VITE_LIVE2D_CUBISM_CORE=/live2d/live2dcubismcore.min.js
|
VITE_LIVE2D_CUBISM_CORE=/live2d/live2dcubismcore.min.js
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -19,6 +20,9 @@ from .auth_types import AccountStatus, RoleName
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import get_pool
|
from .db import get_pool
|
||||||
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
|
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
|
||||||
|
from .services import notifications
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -351,6 +355,24 @@ async def _runtime_tables_ready(conn) -> bool:
|
||||||
AND table_name = 'session_state'
|
AND table_name = 'session_state'
|
||||||
AND column_name = 'turns_in_stage'
|
AND column_name = 'turns_in_stage'
|
||||||
) AS has_state_columns,
|
) 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
|
SELECT count(*) = 4
|
||||||
FROM app.stage_def
|
FROM app.stage_def
|
||||||
|
|
@ -463,6 +485,8 @@ async def _runtime_tables_ready(conn) -> bool:
|
||||||
and row["has_engine_config"]
|
and row["has_engine_config"]
|
||||||
and row["has_session_columns"]
|
and row["has_session_columns"]
|
||||||
and row["has_state_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_stage_defs"]
|
||||||
and row["has_admin_health_event"]
|
and row["has_admin_health_event"]
|
||||||
and row["has_admin_health_daily_rollup"]
|
and row["has_admin_health_daily_rollup"]
|
||||||
|
|
@ -1813,6 +1837,16 @@ async def create_session(
|
||||||
reactivate=False,
|
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
|
expires_at = time.time() + settings.session_ttl_seconds
|
||||||
user = SessionUser(
|
user = SessionUser(
|
||||||
user_id=managed.user_id,
|
user_id=managed.user_id,
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,28 @@ class Settings(BaseSettings):
|
||||||
validation_alias="USER_UPLOAD_DIR",
|
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 (정적 프론트 + SSE 분리경로) ────────────────
|
||||||
cors_origins: list[str] = Field(
|
cors_origins: list[str] = Field(
|
||||||
default=[
|
default=[
|
||||||
|
|
@ -278,9 +300,16 @@ class Settings(BaseSettings):
|
||||||
for origin in self.frontend_origin_map.values()
|
for origin in self.frontend_origin_map.values()
|
||||||
):
|
):
|
||||||
forbidden.append("FRONTEND_ORIGIN_MAP")
|
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:
|
if forbidden:
|
||||||
joined = ", ".join(forbidden)
|
joined = ", ".join(forbidden)
|
||||||
raise ValueError(f"{joined} must be production-safe when ENVIRONMENT={self.environment}")
|
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:
|
if self.auth_saml_enabled:
|
||||||
missing_saml: list[str] = []
|
missing_saml: list[str] = []
|
||||||
if not self.saml_sp_entity_id.strip():
|
if not self.saml_sp_entity_id.strip():
|
||||||
|
|
|
||||||
|
|
@ -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_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_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.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(
|
return bool(
|
||||||
|
|
@ -163,6 +186,13 @@ async def healthcheck() -> bool:
|
||||||
and row["has_admin_health_event"]
|
and row["has_admin_health_event"]
|
||||||
and row["has_admin_health_daily_rollup"]
|
and row["has_admin_health_daily_rollup"]
|
||||||
and row["has_support_ticket"]
|
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:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ from .routes import share as share_routes
|
||||||
from .routes import teacher as teacher_routes
|
from .routes import teacher as teacher_routes
|
||||||
from .routes import users as user_routes
|
from .routes import users as user_routes
|
||||||
from .routes import voice as voice_routes
|
from .routes import voice as voice_routes
|
||||||
|
from .services.notifications import ensure_notification_tables
|
||||||
from .services.voice import voice_service
|
from .services.voice import voice_service
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -44,6 +45,7 @@ async def lifespan(app: FastAPI):
|
||||||
await init_pool()
|
await init_pool()
|
||||||
await ensure_runtime_tables()
|
await ensure_runtime_tables()
|
||||||
await ensure_review_tables()
|
await ensure_review_tables()
|
||||||
|
await ensure_notification_tables()
|
||||||
if settings.auto_seed_personas:
|
if settings.auto_seed_personas:
|
||||||
await materialize_seed_personas()
|
await materialize_seed_personas()
|
||||||
await admin_routes.apply_engine_config_from_store()
|
await admin_routes.apply_engine_config_from_store()
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ from ..deps import Principal, require_admin_access
|
||||||
from ..engine_client import engine_client
|
from ..engine_client import engine_client
|
||||||
from ..runtime_policy import require_runtime_fallback_allowed
|
from ..runtime_policy import require_runtime_fallback_allowed
|
||||||
from ..services.voice import voice_service
|
from ..services.voice import voice_service
|
||||||
from ..services import evaluator, rag
|
from ..services import evaluator, notifications, rag
|
||||||
from ..store import store
|
from ..store import store
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
@ -49,6 +49,7 @@ TicketCategory = Literal[
|
||||||
]
|
]
|
||||||
TicketPriority = Literal["low", "normal", "high", "urgent"]
|
TicketPriority = Literal["low", "normal", "high", "urgent"]
|
||||||
TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"]
|
TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"]
|
||||||
|
NotificationDeliveryStatus = Literal["queued", "sending", "sent", "failed", "skipped"]
|
||||||
|
|
||||||
|
|
||||||
class AdminServiceHealth(BaseModel):
|
class AdminServiceHealth(BaseModel):
|
||||||
|
|
@ -258,6 +259,46 @@ class AdminUserPatch(BaseModel):
|
||||||
cohort_ids: list[str] | None = None
|
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):
|
class RuntimeHealthMetrics(BaseModel):
|
||||||
engine_latency_ms: float | None = None
|
engine_latency_ms: float | None = None
|
||||||
db_pool_size: int = 0
|
db_pool_size: int = 0
|
||||||
|
|
@ -1009,6 +1050,25 @@ def _row_value(row, key: str, default=None):
|
||||||
return default
|
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:
|
def _engine_config_from_row(row) -> AdminEngineConfigResponse:
|
||||||
return AdminEngineConfigResponse(
|
return AdminEngineConfigResponse(
|
||||||
engine_mode=_normalize_engine_mode(row["engine_mode"]),
|
engine_mode=_normalize_engine_mode(row["engine_mode"]),
|
||||||
|
|
@ -1456,6 +1516,101 @@ async def list_tickets(
|
||||||
return _unavailable_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)
|
@router.patch("/tickets/{ticket_id}", response_model=AdminSupportTicketResponse)
|
||||||
async def patch_ticket(
|
async def patch_ticket(
|
||||||
ticket_id: str,
|
ticket_id: str,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ from ..services import (
|
||||||
guardrail,
|
guardrail,
|
||||||
live_coach,
|
live_coach,
|
||||||
memory,
|
memory,
|
||||||
|
notifications,
|
||||||
orchestrator,
|
orchestrator,
|
||||||
rag,
|
rag,
|
||||||
session_digest_worker,
|
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:
|
def _ensure_learner(principal: Principal) -> Principal:
|
||||||
if principal.role == Role.LEARNER:
|
if principal.role == Role.LEARNER:
|
||||||
return principal
|
return principal
|
||||||
if principal.super_admin:
|
if principal.can_access_role(Role.LEARNER):
|
||||||
return principal.with_role(Role.LEARNER)
|
return principal.with_role(Role.LEARNER)
|
||||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only learners can use sessions")
|
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),
|
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_persistence.SessionEvaluationWrite.from_result(
|
||||||
session_id=sess.session_id,
|
session_id=sess.session_id,
|
||||||
learner_id=sess.learner_id,
|
learner_id=sess.learner_id,
|
||||||
result=result,
|
result=result,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if saved:
|
||||||
|
await _enqueue_session_review_ready_notification(sess.session_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await session_persistence.save_session_evaluation(
|
saved = await session_persistence.save_session_evaluation(
|
||||||
session_persistence.SessionEvaluationWrite.from_error(
|
session_persistence.SessionEvaluationWrite.from_error(
|
||||||
session_id=sess.session_id,
|
session_id=sess.session_id,
|
||||||
learner_id=sess.learner_id,
|
learner_id=sess.learner_id,
|
||||||
|
|
@ -671,6 +674,15 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
||||||
error=str(exc),
|
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:
|
def _schedule_session_evaluation(sess: InProcSession) -> None:
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,7 @@ class NotificationPreferences(BaseModel):
|
||||||
session_done: bool = True
|
session_done: bool = True
|
||||||
safety_signal: bool = True
|
safety_signal: bool = True
|
||||||
learner_progress: bool = False
|
learner_progress: bool = False
|
||||||
|
account_approval: bool = True
|
||||||
product_news: bool = False
|
product_news: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -580,7 +581,7 @@ async def complete_onboarding(
|
||||||
|
|
||||||
principal.display_name = updated.display_name
|
principal.display_name = updated.display_name
|
||||||
principal.profile_completed_at = updated.profile_completed_at
|
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)
|
principal.consent_at = await record_user_consent(principal.user_id)
|
||||||
return await _profile_for(principal)
|
return await _profile_for(principal)
|
||||||
|
|
||||||
|
|
|
||||||
744
apps/api/app/services/notifications.py
Normal file
744
apps/api/app/services/notifications.py
Normal file
|
|
@ -0,0 +1,744 @@
|
||||||
|
"""Operational email notifications for review and account-approval work."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import html
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr, make_msgid
|
||||||
|
from typing import Any, Literal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..db import acquire, get_pool
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NotificationKind = Literal["account_pending_approval", "session_review_ready", "admin_test_email"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class NotificationRecipient:
|
||||||
|
user_id: str | None
|
||||||
|
email: str
|
||||||
|
display_name: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RenderedEmail:
|
||||||
|
subject: str
|
||||||
|
preheader: str
|
||||||
|
html: str
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_notification_tables() -> None:
|
||||||
|
"""Create notification queue tables when the DB role allows DDL."""
|
||||||
|
get_pool()
|
||||||
|
async with acquire(role="admin") as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS app.notification_event (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
kind TEXT NOT NULL CHECK (
|
||||||
|
kind IN ('account_pending_approval','session_review_ready','admin_test_email')
|
||||||
|
),
|
||||||
|
actor_user_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL,
|
||||||
|
subject_user_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL,
|
||||||
|
session_id UUID REFERENCES app.sessions(id) ON DELETE CASCADE,
|
||||||
|
idempotency_key TEXT NOT NULL UNIQUE,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS app.notification_delivery (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id UUID NOT NULL REFERENCES app.notification_event(id) ON DELETE CASCADE,
|
||||||
|
channel TEXT NOT NULL DEFAULT 'email' CHECK (channel IN ('email')),
|
||||||
|
recipient_user_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL,
|
||||||
|
recipient_email TEXT NOT NULL,
|
||||||
|
recipient_name TEXT NOT NULL DEFAULT '',
|
||||||
|
recipient_role TEXT NOT NULL DEFAULT '',
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued' CHECK (
|
||||||
|
status IN ('queued','sending','sent','failed','skipped')
|
||||||
|
),
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
last_error TEXT,
|
||||||
|
provider_message_id TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (event_id, channel, recipient_email)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE app.notification_event
|
||||||
|
DROP CONSTRAINT IF EXISTS notification_event_kind_check;
|
||||||
|
ALTER TABLE app.notification_event
|
||||||
|
ADD CONSTRAINT notification_event_kind_check
|
||||||
|
CHECK (kind IN ('account_pending_approval','session_review_ready','admin_test_email'));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_event_created
|
||||||
|
ON app.notification_event(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_delivery_queue
|
||||||
|
ON app.notification_delivery(status, next_attempt_at, created_at)
|
||||||
|
WHERE status IN ('queued','failed');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_delivery_event
|
||||||
|
ON app.notification_delivery(event_id, created_at);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE app.notification_event ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE app.notification_delivery ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS p_notification_event_admin_all ON app.notification_event;
|
||||||
|
DROP POLICY IF EXISTS p_notification_delivery_admin_all ON app.notification_delivery;
|
||||||
|
|
||||||
|
CREATE POLICY p_notification_event_admin_all
|
||||||
|
ON app.notification_event FOR ALL
|
||||||
|
USING (app.current_role_name() = 'admin')
|
||||||
|
WITH CHECK (app.current_role_name() = 'admin');
|
||||||
|
CREATE POLICY p_notification_delivery_admin_all
|
||||||
|
ON app.notification_delivery FOR ALL
|
||||||
|
USING (app.current_role_name() = 'admin')
|
||||||
|
WITH CHECK (app.current_role_name() = 'admin');
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_delivery_flush() -> None:
|
||||||
|
"""Best-effort background drain; failed SMTP remains queued for a worker."""
|
||||||
|
if settings.notification_email_provider == "disabled":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop().create_task(process_queued_email_notifications(limit=10))
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_account_pending_approval(
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
email: str,
|
||||||
|
display_name: str,
|
||||||
|
role: str,
|
||||||
|
) -> None:
|
||||||
|
"""Notify administrators that a newly signed-in account needs approval."""
|
||||||
|
if not user_id or not email:
|
||||||
|
return
|
||||||
|
payload = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"email": email,
|
||||||
|
"display_name": display_name or email,
|
||||||
|
"role": role,
|
||||||
|
"approval_url": _frontend_url("/admin/users"),
|
||||||
|
}
|
||||||
|
recipients = await _admin_approval_recipients()
|
||||||
|
await _enqueue_event(
|
||||||
|
kind="account_pending_approval",
|
||||||
|
idempotency_key=f"account_pending_approval:{user_id}",
|
||||||
|
subject_user_id=user_id,
|
||||||
|
session_id=None,
|
||||||
|
payload=payload,
|
||||||
|
recipients=recipients,
|
||||||
|
)
|
||||||
|
schedule_delivery_flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_session_review_ready(*, session_id: str) -> None:
|
||||||
|
"""Notify assigned teachers/admins that an ended session is ready to review."""
|
||||||
|
if not session_id:
|
||||||
|
return
|
||||||
|
payload, recipients = await _session_review_payload_and_recipients(session_id)
|
||||||
|
if payload is None:
|
||||||
|
return
|
||||||
|
await _enqueue_event(
|
||||||
|
kind="session_review_ready",
|
||||||
|
idempotency_key=f"session_review_ready:{session_id}",
|
||||||
|
subject_user_id=str(payload.get("learner_id") or "") or None,
|
||||||
|
session_id=session_id,
|
||||||
|
payload=payload,
|
||||||
|
recipients=recipients,
|
||||||
|
)
|
||||||
|
schedule_delivery_flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_admin_test_email(
|
||||||
|
*,
|
||||||
|
actor_user_id: str | None = None,
|
||||||
|
actor_email: str = "",
|
||||||
|
) -> int:
|
||||||
|
"""Queue one explicit admin test email through the normal delivery path."""
|
||||||
|
payload = {
|
||||||
|
"requested_by": actor_email,
|
||||||
|
"notifications_url": _frontend_url("/admin"),
|
||||||
|
}
|
||||||
|
recipients = await _admin_approval_recipients()
|
||||||
|
await _enqueue_event(
|
||||||
|
kind="admin_test_email",
|
||||||
|
idempotency_key=f"admin_test_email:{uuid4()}",
|
||||||
|
subject_user_id=actor_user_id,
|
||||||
|
session_id=None,
|
||||||
|
payload=payload,
|
||||||
|
recipients=recipients,
|
||||||
|
)
|
||||||
|
schedule_delivery_flush()
|
||||||
|
return len(recipients)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_queued_email_notifications(*, limit: int = 25) -> dict[str, int]:
|
||||||
|
"""Send queued email deliveries and record durable delivery state."""
|
||||||
|
get_pool()
|
||||||
|
processed = 0
|
||||||
|
sent = 0
|
||||||
|
failed = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
async with acquire(role="admin") as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
d.id,
|
||||||
|
d.recipient_email,
|
||||||
|
d.recipient_name,
|
||||||
|
d.attempts,
|
||||||
|
e.kind,
|
||||||
|
e.payload
|
||||||
|
FROM app.notification_delivery d
|
||||||
|
JOIN app.notification_event e ON e.id = d.event_id
|
||||||
|
WHERE d.channel = 'email'
|
||||||
|
AND d.status IN ('queued','failed')
|
||||||
|
AND d.next_attempt_at <= now()
|
||||||
|
AND d.attempts < $1
|
||||||
|
ORDER BY d.created_at
|
||||||
|
LIMIT $2
|
||||||
|
""",
|
||||||
|
settings.notification_email_max_attempts,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
delivery_id = str(row["id"])
|
||||||
|
processed += 1
|
||||||
|
await _mark_delivery_sending(delivery_id)
|
||||||
|
rendered = render_email(row["kind"], row["payload"] or {})
|
||||||
|
try:
|
||||||
|
provider_id = await asyncio.to_thread(
|
||||||
|
_send_rendered_email,
|
||||||
|
recipient_email=str(row["recipient_email"]),
|
||||||
|
recipient_name=str(row["recipient_name"] or ""),
|
||||||
|
rendered=rendered,
|
||||||
|
)
|
||||||
|
except NotificationSkipped as exc:
|
||||||
|
skipped += 1
|
||||||
|
await _mark_delivery_skipped(delivery_id, str(exc))
|
||||||
|
except Exception as exc: # SMTP/library errors should not break app flows.
|
||||||
|
failed += 1
|
||||||
|
await _mark_delivery_failed(delivery_id, str(exc))
|
||||||
|
else:
|
||||||
|
sent += 1
|
||||||
|
await _mark_delivery_sent(delivery_id, provider_id)
|
||||||
|
|
||||||
|
return {"processed": processed, "sent": sent, "failed": failed, "skipped": skipped}
|
||||||
|
|
||||||
|
|
||||||
|
def render_email(kind: str, payload: dict[str, Any]) -> RenderedEmail:
|
||||||
|
if kind == "account_pending_approval":
|
||||||
|
return _render_account_pending_approval(payload)
|
||||||
|
if kind == "session_review_ready":
|
||||||
|
return _render_session_review_ready(payload)
|
||||||
|
if kind == "admin_test_email":
|
||||||
|
return _render_admin_test_email(payload)
|
||||||
|
raise ValueError(f"unsupported notification kind: {kind}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _enqueue_event(
|
||||||
|
*,
|
||||||
|
kind: NotificationKind,
|
||||||
|
idempotency_key: str,
|
||||||
|
subject_user_id: str | None,
|
||||||
|
session_id: str | None,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
recipients: list[NotificationRecipient],
|
||||||
|
) -> None:
|
||||||
|
if not recipients:
|
||||||
|
logger.warning("notification %s has no recipients", idempotency_key)
|
||||||
|
get_pool()
|
||||||
|
rendered = render_email(kind, payload)
|
||||||
|
async with acquire(role="admin") as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO app.notification_event (
|
||||||
|
kind, subject_user_id, session_id, idempotency_key, payload
|
||||||
|
)
|
||||||
|
VALUES ($1, $2::uuid, $3::uuid, $4, $5::jsonb)
|
||||||
|
ON CONFLICT (idempotency_key) DO UPDATE SET
|
||||||
|
payload = app.notification_event.payload
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
kind,
|
||||||
|
subject_user_id,
|
||||||
|
session_id,
|
||||||
|
idempotency_key,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
event_id = str(row["id"])
|
||||||
|
for recipient in recipients:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO app.notification_delivery (
|
||||||
|
event_id, recipient_user_id, recipient_email, recipient_name,
|
||||||
|
recipient_role, subject
|
||||||
|
)
|
||||||
|
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (event_id, channel, recipient_email) DO NOTHING
|
||||||
|
""",
|
||||||
|
event_id,
|
||||||
|
recipient.user_id,
|
||||||
|
recipient.email,
|
||||||
|
recipient.display_name,
|
||||||
|
recipient.role,
|
||||||
|
rendered.subject,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _admin_approval_recipients() -> list[NotificationRecipient]:
|
||||||
|
super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
|
||||||
|
async with acquire(role="admin") as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT
|
||||||
|
u.user_id,
|
||||||
|
u.email,
|
||||||
|
COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
|
||||||
|
u.role
|
||||||
|
FROM app.app_user u
|
||||||
|
LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
|
||||||
|
WHERE u.is_active
|
||||||
|
AND u.account_status = 'approved'
|
||||||
|
AND u.email IS NOT NULL
|
||||||
|
AND u.email <> ''
|
||||||
|
AND (
|
||||||
|
u.role = 'admin'
|
||||||
|
OR u.admin_access
|
||||||
|
OR lower(u.email) = ANY($1::text[])
|
||||||
|
)
|
||||||
|
AND COALESCE((p.notifications->>'account_approval')::boolean, true)
|
||||||
|
ORDER BY u.email
|
||||||
|
""",
|
||||||
|
super_admin_emails,
|
||||||
|
)
|
||||||
|
return [_recipient_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def _session_review_payload_and_recipients(
|
||||||
|
session_id: str,
|
||||||
|
) -> tuple[dict[str, Any] | None, list[NotificationRecipient]]:
|
||||||
|
super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
|
||||||
|
async with acquire(role="admin") as conn:
|
||||||
|
session = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.session_no,
|
||||||
|
s.ended_at,
|
||||||
|
s.learner_id,
|
||||||
|
s.persona_id,
|
||||||
|
learner.email AS learner_email,
|
||||||
|
COALESCE(NULLIF(learner.display_name, ''), learner.email, '학습자') AS learner_label,
|
||||||
|
learner.cohort AS learner_cohort,
|
||||||
|
COALESCE(NULLIF(pc.display_name, ''), s.persona_id::text) AS persona_name
|
||||||
|
FROM app.sessions s
|
||||||
|
LEFT JOIN app.app_user learner ON learner.user_id = s.learner_id
|
||||||
|
LEFT JOIN app.persona_card pc
|
||||||
|
ON pc.persona_id = s.persona_id
|
||||||
|
AND pc.version = s.persona_version
|
||||||
|
WHERE s.id = $1::uuid
|
||||||
|
AND s.ended_at IS NOT NULL
|
||||||
|
""",
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
if session is None:
|
||||||
|
return None, []
|
||||||
|
learner_cohort = str(session["learner_cohort"] or "")
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT
|
||||||
|
u.user_id,
|
||||||
|
u.email,
|
||||||
|
COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
|
||||||
|
u.role
|
||||||
|
FROM app.app_user u
|
||||||
|
LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
|
||||||
|
WHERE u.is_active
|
||||||
|
AND u.account_status = 'approved'
|
||||||
|
AND u.email IS NOT NULL
|
||||||
|
AND u.email <> ''
|
||||||
|
AND (
|
||||||
|
u.role = 'admin'
|
||||||
|
OR lower(u.email) = ANY($1::text[])
|
||||||
|
OR (
|
||||||
|
u.role = 'instructor'
|
||||||
|
AND ($2 = '' OR u.cohort = $2)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND COALESCE((p.notifications->>'learner_progress')::boolean, true)
|
||||||
|
ORDER BY u.email
|
||||||
|
""",
|
||||||
|
super_admin_emails,
|
||||||
|
learner_cohort,
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"session_id": str(session["id"]),
|
||||||
|
"session_no": int(session["session_no"] or 0),
|
||||||
|
"ended_at": _iso_text(session["ended_at"]),
|
||||||
|
"learner_id": str(session["learner_id"] or ""),
|
||||||
|
"learner_email": str(session["learner_email"] or ""),
|
||||||
|
"learner_label": str(session["learner_label"] or "학습자"),
|
||||||
|
"learner_cohort": learner_cohort,
|
||||||
|
"persona_id": str(session["persona_id"] or ""),
|
||||||
|
"persona_name": str(session["persona_name"] or "내담자"),
|
||||||
|
"review_url": _frontend_url(f"/teach/session/{session_id}/review"),
|
||||||
|
}
|
||||||
|
return payload, [_recipient_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _send_rendered_email(
|
||||||
|
*,
|
||||||
|
recipient_email: str,
|
||||||
|
recipient_name: str,
|
||||||
|
rendered: RenderedEmail,
|
||||||
|
) -> str:
|
||||||
|
if settings.notification_email_provider == "disabled":
|
||||||
|
raise NotificationSkipped("email provider is disabled")
|
||||||
|
if settings.notification_email_provider != "smtp":
|
||||||
|
raise NotificationSkipped(f"unsupported provider: {settings.notification_email_provider}")
|
||||||
|
if not settings.smtp_host.strip() or not settings.smtp_from_email.strip():
|
||||||
|
raise NotificationSkipped("SMTP host/from email is not configured")
|
||||||
|
|
||||||
|
message_id = make_msgid(domain=_message_id_domain(settings.smtp_from_email))
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = rendered.subject
|
||||||
|
msg["From"] = formataddr((settings.smtp_from_name, settings.smtp_from_email))
|
||||||
|
msg["To"] = formataddr((recipient_name, recipient_email))
|
||||||
|
msg["Message-ID"] = message_id
|
||||||
|
msg.set_content(rendered.text)
|
||||||
|
msg.add_alternative(rendered.html, subtype="html")
|
||||||
|
|
||||||
|
if settings.smtp_ssl:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, context=context, timeout=15) as smtp:
|
||||||
|
_smtp_login_if_needed(smtp)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
else:
|
||||||
|
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as smtp:
|
||||||
|
if settings.smtp_starttls:
|
||||||
|
smtp.starttls(context=ssl.create_default_context())
|
||||||
|
_smtp_login_if_needed(smtp)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
return message_id
|
||||||
|
|
||||||
|
|
||||||
|
def _smtp_login_if_needed(smtp: smtplib.SMTP) -> None:
|
||||||
|
if settings.smtp_username:
|
||||||
|
smtp.login(settings.smtp_username, settings.smtp_password)
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_delivery_sending(delivery_id: str) -> None:
|
||||||
|
async with acquire(role="admin") as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE app.notification_delivery
|
||||||
|
SET status = 'sending',
|
||||||
|
attempts = attempts + 1,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1::uuid
|
||||||
|
""",
|
||||||
|
delivery_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_delivery_sent(delivery_id: str, provider_id: str) -> None:
|
||||||
|
async with acquire(role="admin") as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE app.notification_delivery
|
||||||
|
SET status = 'sent',
|
||||||
|
sent_at = now(),
|
||||||
|
last_error = NULL,
|
||||||
|
provider_message_id = $2,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1::uuid
|
||||||
|
""",
|
||||||
|
delivery_id,
|
||||||
|
provider_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_delivery_skipped(delivery_id: str, reason: str) -> None:
|
||||||
|
async with acquire(role="admin") as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE app.notification_delivery
|
||||||
|
SET status = 'skipped',
|
||||||
|
last_error = $2,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1::uuid
|
||||||
|
""",
|
||||||
|
delivery_id,
|
||||||
|
reason[:1000],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_delivery_failed(delivery_id: str, error: str) -> None:
|
||||||
|
async with acquire(role="admin") as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE app.notification_delivery
|
||||||
|
SET status = 'failed',
|
||||||
|
last_error = $2,
|
||||||
|
next_attempt_at = now() + ($3::text || ' seconds')::interval,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1::uuid
|
||||||
|
""",
|
||||||
|
delivery_id,
|
||||||
|
error[:1000],
|
||||||
|
settings.notification_email_retry_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_account_pending_approval(payload: dict[str, Any]) -> RenderedEmail:
|
||||||
|
display_name = str(payload.get("display_name") or payload.get("email") or "신규 사용자")
|
||||||
|
email = str(payload.get("email") or "")
|
||||||
|
role = _role_label(str(payload.get("role") or "learner"))
|
||||||
|
approval_url = str(payload.get("approval_url") or _frontend_url("/admin/users"))
|
||||||
|
subject = "[Vignette] 새 가입 승인 요청이 있습니다"
|
||||||
|
preheader = "승인 대기 중인 신규 사용자가 있습니다."
|
||||||
|
html_body = f"""
|
||||||
|
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
|
||||||
|
승인 대기 중인 신규 사용자가 있습니다. 가입 승인 화면에서 수업 또는 연구 참여 범위를 확인한 뒤 승인 상태를 결정해 주세요.
|
||||||
|
</p>
|
||||||
|
{_info_box([
|
||||||
|
("사용자", display_name),
|
||||||
|
("이메일", email),
|
||||||
|
("요청 역할", role),
|
||||||
|
])}
|
||||||
|
{_button("가입 승인 확인하기", approval_url)}
|
||||||
|
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
|
||||||
|
계정 승인 전까지 해당 사용자는 Vignette 대기 화면만 볼 수 있습니다.
|
||||||
|
</p>
|
||||||
|
"""
|
||||||
|
html_doc = _email_shell(
|
||||||
|
kicker="관리자 알림",
|
||||||
|
title="새 가입 승인 요청이 있습니다.",
|
||||||
|
preheader=preheader,
|
||||||
|
body=html_body,
|
||||||
|
)
|
||||||
|
text = (
|
||||||
|
"Vignette 관리자 알림\n\n"
|
||||||
|
"새 가입 승인 요청이 있습니다.\n"
|
||||||
|
f"사용자: {display_name}\n"
|
||||||
|
f"이메일: {email}\n"
|
||||||
|
f"요청 역할: {role}\n\n"
|
||||||
|
f"가입 승인 확인하기: {approval_url}\n"
|
||||||
|
)
|
||||||
|
return RenderedEmail(subject=subject, preheader=preheader, html=html_doc, text=text)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_session_review_ready(payload: dict[str, Any]) -> RenderedEmail:
|
||||||
|
learner_label = str(payload.get("learner_label") or "학습자")
|
||||||
|
persona_name = str(payload.get("persona_name") or "내담자")
|
||||||
|
session_no = str(payload.get("session_no") or "")
|
||||||
|
ended_at = str(payload.get("ended_at") or "")
|
||||||
|
review_url = str(payload.get("review_url") or _frontend_url("/teach"))
|
||||||
|
subject = "[Vignette] 검토할 회기가 있습니다"
|
||||||
|
preheader = "종료된 학습 회기를 확인하고 검토 상태를 남겨 주세요."
|
||||||
|
html_body = f"""
|
||||||
|
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
|
||||||
|
종료된 학습 회기가 교수자 검토 대기 상태입니다. 회기 리뷰 화면에서 요약과 근거를 확인한 뒤 검토 상태를 남겨 주세요.
|
||||||
|
</p>
|
||||||
|
{_info_box([
|
||||||
|
("학습자", learner_label),
|
||||||
|
("내담자", persona_name),
|
||||||
|
("회기", f"{session_no}회기" if session_no else "종료 회기"),
|
||||||
|
("종료 시각", ended_at or "기록됨"),
|
||||||
|
])}
|
||||||
|
{_button("회기 검토하기", review_url)}
|
||||||
|
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
|
||||||
|
민감한 회기 내용은 메일에 포함하지 않았습니다. 로그인 후 Vignette에서 확인해 주세요.
|
||||||
|
</p>
|
||||||
|
"""
|
||||||
|
html_doc = _email_shell(
|
||||||
|
kicker="교수자 검토 알림",
|
||||||
|
title="검토할 회기가 있습니다.",
|
||||||
|
preheader=preheader,
|
||||||
|
body=html_body,
|
||||||
|
)
|
||||||
|
text = (
|
||||||
|
"Vignette 교수자 검토 알림\n\n"
|
||||||
|
"검토할 회기가 있습니다.\n"
|
||||||
|
f"학습자: {learner_label}\n"
|
||||||
|
f"내담자: {persona_name}\n"
|
||||||
|
f"회기: {session_no}회기\n"
|
||||||
|
f"종료 시각: {ended_at}\n\n"
|
||||||
|
f"회기 검토하기: {review_url}\n"
|
||||||
|
"\n민감한 회기 내용은 메일에 포함하지 않았습니다.\n"
|
||||||
|
)
|
||||||
|
return RenderedEmail(subject=subject, preheader=preheader, html=html_doc, text=text)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_admin_test_email(payload: dict[str, Any]) -> RenderedEmail:
|
||||||
|
requested_by = str(payload.get("requested_by") or "관리자")
|
||||||
|
notifications_url = str(payload.get("notifications_url") or _frontend_url("/admin"))
|
||||||
|
subject = "[Vignette] 메일 알림 테스트입니다"
|
||||||
|
preheader = "관리자 메일 알림 경로가 정상적으로 연결되었는지 확인합니다."
|
||||||
|
html_body = f"""
|
||||||
|
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
|
||||||
|
관리자 메일 알림이 정상적으로 연결되었습니다. 이 메일은 실제 가입 승인이나 회기 검토 요청이 아니라 발송 경로 확인용 테스트입니다.
|
||||||
|
</p>
|
||||||
|
{_info_box([
|
||||||
|
("요청자", requested_by),
|
||||||
|
("용도", "운영 메일 발송 테스트"),
|
||||||
|
])}
|
||||||
|
{_button("알림 상태 확인하기", notifications_url)}
|
||||||
|
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
|
||||||
|
이후 가입 승인 요청과 회기 검토 요청도 같은 메일 템플릿과 발송 큐를 사용합니다.
|
||||||
|
</p>
|
||||||
|
"""
|
||||||
|
html_doc = _email_shell(
|
||||||
|
kicker="관리자 테스트 알림",
|
||||||
|
title="메일 알림 테스트입니다.",
|
||||||
|
preheader=preheader,
|
||||||
|
body=html_body,
|
||||||
|
)
|
||||||
|
text = (
|
||||||
|
"Vignette 관리자 테스트 알림\n\n"
|
||||||
|
"메일 알림 테스트입니다.\n"
|
||||||
|
"관리자 메일 알림이 정상적으로 연결되었습니다.\n"
|
||||||
|
f"요청자: {requested_by}\n\n"
|
||||||
|
f"알림 상태 확인하기: {notifications_url}\n"
|
||||||
|
)
|
||||||
|
return RenderedEmail(subject=subject, preheader=preheader, html=html_doc, text=text)
|
||||||
|
|
||||||
|
|
||||||
|
def _email_shell(*, kicker: str, title: str, preheader: str, body: str) -> str:
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{_h(title)}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;background:#fbfaf8;font-family:Pretendard,-apple-system,BlinkMacSystemFont,'Apple SD Gothic Neo','Noto Sans KR',sans-serif;color:#1c2a2a;">
|
||||||
|
<span style="display:none!important;visibility:hidden;opacity:0;color:transparent;height:0;width:0;overflow:hidden;">{_h(preheader)}</span>
|
||||||
|
<div style="max-width:640px;margin:0 auto;padding:32px 20px;">
|
||||||
|
<div style="font-size:14px;font-weight:800;color:#3e7a6e;margin-bottom:16px;letter-spacing:0;">Vignette</div>
|
||||||
|
<section style="background:#ffffff;border:1px solid #e5e1da;border-radius:8px;padding:28px;box-shadow:0 1px 2px rgba(28,42,42,0.05);">
|
||||||
|
<p style="margin:0 0 8px;font-size:12px;font-weight:800;color:#5a6663;">{_h(kicker)}</p>
|
||||||
|
<h1 style="margin:0 0 14px;font-size:24px;line-height:1.35;color:#1c2a2a;font-weight:800;letter-spacing:0;">{_h(title)}</h1>
|
||||||
|
{body}
|
||||||
|
</section>
|
||||||
|
<p style="margin:16px 0 0;font-size:11px;line-height:1.6;color:#93a09c;">
|
||||||
|
이 메일은 Vignette 운영 알림입니다. 버튼이 열리지 않으면 링크를 복사해 브라우저 주소창에 붙여 넣어 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _info_box(items: list[tuple[str, str]]) -> str:
|
||||||
|
rows = "".join(
|
||||||
|
f"""
|
||||||
|
<tr>
|
||||||
|
<td style="padding:5px 0;width:92px;color:#5a6663;font-size:13px;">{_h(label)}</td>
|
||||||
|
<td style="padding:5px 0;color:#1c2a2a;font-size:14px;font-weight:700;">{_h(value)}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
for label, value in items
|
||||||
|
if value
|
||||||
|
)
|
||||||
|
return f"""
|
||||||
|
<div style="background:#eef4f2;border-radius:8px;padding:14px 16px;margin-bottom:24px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;">
|
||||||
|
{rows}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _button(label: str, url: str) -> str:
|
||||||
|
safe_url = _h(url)
|
||||||
|
return f"""
|
||||||
|
<a href="{safe_url}"
|
||||||
|
style="display:inline-block;background:#3e7a6e;color:#fbfaf8;text-decoration:none;border-radius:8px;padding:12px 18px;font-size:14px;font-weight:800;">
|
||||||
|
{_h(label)}
|
||||||
|
</a>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _frontend_url(path: str) -> str:
|
||||||
|
base = settings.frontend_base_url.rstrip("/") or "http://localhost:5173"
|
||||||
|
suffix = "/" + path.lstrip("/")
|
||||||
|
return f"{base}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _recipient_from_row(row: Any) -> NotificationRecipient:
|
||||||
|
return NotificationRecipient(
|
||||||
|
user_id=str(row["user_id"]) if row["user_id"] else None,
|
||||||
|
email=str(row["email"]),
|
||||||
|
display_name=str(row["display_name"] or row["email"]),
|
||||||
|
role=str(row["role"] or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_email(value: str) -> str:
|
||||||
|
return value.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _role_label(value: str) -> str:
|
||||||
|
if value in {"teacher", "instructor"}:
|
||||||
|
return "교수자"
|
||||||
|
if value == "admin":
|
||||||
|
return "관리자"
|
||||||
|
return "학습자"
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_text(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
iso = getattr(value, "isoformat", None)
|
||||||
|
if callable(iso):
|
||||||
|
return iso()
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_id_domain(email: str) -> str:
|
||||||
|
if "@" in email:
|
||||||
|
return email.rsplit("@", 1)[1]
|
||||||
|
return "vignette.local"
|
||||||
|
|
||||||
|
|
||||||
|
def _h(value: str) -> str:
|
||||||
|
return html.escape(value, quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSkipped(RuntimeError):
|
||||||
|
"""Raised when a delivery is intentionally not sent."""
|
||||||
|
|
@ -8,7 +8,7 @@ from contextlib import contextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import parse_qs, urlencode, urlsplit
|
from urllib.parse import parse_qs, urlencode, urlsplit
|
||||||
|
|
||||||
from fastapi import Response
|
from fastapi import HTTPException, Response
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
|
@ -17,6 +17,7 @@ from .config import Settings, settings
|
||||||
from .deps import Principal, Role
|
from .deps import Principal, Role
|
||||||
from .routes import admin as admin_routes
|
from .routes import admin as admin_routes
|
||||||
from .routes import auth as auth_routes
|
from .routes import auth as auth_routes
|
||||||
|
from .routes import sessions as session_routes
|
||||||
from .saml import inflate_redirect_request
|
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")
|
hoonjung_role = auth_routes._role_for_email("hoonjungkoo@hs.ac.kr")
|
||||||
self.assertEqual(hoonjung_role, Role.ADMIN)
|
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:
|
async def test_super_admin_can_enter_teacher_and_learner_role_guards(self) -> None:
|
||||||
principal = Principal(
|
principal = Principal(
|
||||||
user_id="00000000-0000-0000-0000-000000000601",
|
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.TEACHER))
|
||||||
self.assertTrue(principal.can_access_role(Role.ADMIN))
|
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:
|
async def test_only_super_admin_can_grant_admin_access(self) -> None:
|
||||||
with (
|
with (
|
||||||
patched_settings(
|
patched_settings(
|
||||||
|
|
|
||||||
186
apps/api/app/test_notifications.py
Normal file
186
apps/api/app/test_notifications.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""Notification email rendering and enqueue trigger tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .routes import sessions
|
||||||
|
from .services import notifications, state_machine
|
||||||
|
from .services.persona import P1
|
||||||
|
from .store import InProcSession, TurnRecord
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def patched_settings(**values: Any):
|
||||||
|
previous = {key: getattr(settings, key) for key in values}
|
||||||
|
for key, value in values.items():
|
||||||
|
setattr(settings, key, value)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
for key, value in previous.items():
|
||||||
|
setattr(settings, key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _ended_session() -> InProcSession:
|
||||||
|
state = state_machine.init_state(params=P1.openness_params())
|
||||||
|
state.stage = state_machine.Stage.EXPLORE
|
||||||
|
return InProcSession(
|
||||||
|
session_id="00000000-0000-0000-0000-00000000e111",
|
||||||
|
case_id="00000000-0000-0000-0000-00000000e111",
|
||||||
|
learner_id="00000000-0000-0000-0000-000000000111",
|
||||||
|
persona_code=P1.code,
|
||||||
|
theory_mode="humanistic",
|
||||||
|
persona=P1,
|
||||||
|
state=state,
|
||||||
|
session_no=1,
|
||||||
|
created_at=1_000.0,
|
||||||
|
ended_at=1_600.0,
|
||||||
|
ended=True,
|
||||||
|
turns=[
|
||||||
|
TurnRecord(
|
||||||
|
turn_seq=1,
|
||||||
|
speaker="counselor",
|
||||||
|
stage=state.stage.value,
|
||||||
|
text="상담자 발화",
|
||||||
|
text_masked="상담자 발화",
|
||||||
|
),
|
||||||
|
TurnRecord(
|
||||||
|
turn_seq=2,
|
||||||
|
speaker="client",
|
||||||
|
stage=state.stage.value,
|
||||||
|
text="내담자 응답",
|
||||||
|
text_masked="내담자 응답",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationEmailTemplateTest(unittest.TestCase):
|
||||||
|
def test_session_review_email_uses_polite_copy_and_direct_review_link(self) -> None:
|
||||||
|
rendered = notifications.render_email(
|
||||||
|
"session_review_ready",
|
||||||
|
{
|
||||||
|
"learner_label": "테스트 학습자",
|
||||||
|
"persona_name": "서연",
|
||||||
|
"session_no": 2,
|
||||||
|
"ended_at": "2026-06-29T09:00:00+09:00",
|
||||||
|
"review_url": "https://vignette.chanpaca.net/teach/session/s1/review",
|
||||||
|
"transcript": "메일에 들어가면 안 되는 축어록",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(rendered.subject, "[Vignette] 검토할 회기가 있습니다")
|
||||||
|
self.assertIn("검토할 회기가 있습니다.", rendered.html)
|
||||||
|
self.assertIn("회기 검토하기", rendered.html)
|
||||||
|
self.assertIn("https://vignette.chanpaca.net/teach/session/s1/review", rendered.html)
|
||||||
|
self.assertIn("민감한 회기 내용은 메일에 포함하지 않았습니다", rendered.html)
|
||||||
|
self.assertNotIn("메일에 들어가면 안 되는 축어록", rendered.html)
|
||||||
|
|
||||||
|
def test_account_approval_email_links_admin_users(self) -> None:
|
||||||
|
rendered = notifications.render_email(
|
||||||
|
"account_pending_approval",
|
||||||
|
{
|
||||||
|
"display_name": "신규 사용자",
|
||||||
|
"email": "new@hs.ac.kr",
|
||||||
|
"role": "learner",
|
||||||
|
"approval_url": "https://vignette.chanpaca.net/admin/users",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(rendered.subject, "[Vignette] 새 가입 승인 요청이 있습니다")
|
||||||
|
self.assertIn("새 가입 승인 요청이 있습니다.", rendered.html)
|
||||||
|
self.assertIn("가입 승인 확인하기", rendered.html)
|
||||||
|
self.assertIn("https://vignette.chanpaca.net/admin/users", rendered.html)
|
||||||
|
|
||||||
|
def test_admin_test_email_uses_distinct_test_copy(self) -> None:
|
||||||
|
rendered = notifications.render_email(
|
||||||
|
"admin_test_email",
|
||||||
|
{
|
||||||
|
"requested_by": "admin@hs.ac.kr",
|
||||||
|
"notifications_url": "https://vignette.chanpaca.net/admin",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(rendered.subject, "[Vignette] 메일 알림 테스트입니다")
|
||||||
|
self.assertIn("메일 알림 테스트입니다.", rendered.html)
|
||||||
|
self.assertIn("발송 경로 확인용 테스트", rendered.html)
|
||||||
|
self.assertIn("알림 상태 확인하기", rendered.html)
|
||||||
|
self.assertIn("https://vignette.chanpaca.net/admin", rendered.html)
|
||||||
|
|
||||||
|
def test_smtp_sender_uses_html_text_parts_and_starttls(self) -> None:
|
||||||
|
rendered = notifications.render_email(
|
||||||
|
"session_review_ready",
|
||||||
|
{
|
||||||
|
"learner_label": "테스트 학습자",
|
||||||
|
"persona_name": "서연",
|
||||||
|
"session_no": 1,
|
||||||
|
"review_url": "https://vignette.chanpaca.net/teach/session/s1/review",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patched_settings(
|
||||||
|
notification_email_provider="smtp",
|
||||||
|
smtp_host="smtp.example.test",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_username="mailer",
|
||||||
|
smtp_password="secret",
|
||||||
|
smtp_from_email="no-reply@example.test",
|
||||||
|
smtp_from_name="Vignette",
|
||||||
|
smtp_starttls=True,
|
||||||
|
smtp_ssl=False,
|
||||||
|
),
|
||||||
|
patch.object(notifications.smtplib, "SMTP") as smtp_class,
|
||||||
|
):
|
||||||
|
smtp = smtp_class.return_value.__enter__.return_value
|
||||||
|
provider_id = notifications._send_rendered_email(
|
||||||
|
recipient_email="teacher@hs.ac.kr",
|
||||||
|
recipient_name="교수자",
|
||||||
|
rendered=rendered,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("@example.test>", provider_id)
|
||||||
|
smtp_class.assert_called_once_with("smtp.example.test", 587, timeout=15)
|
||||||
|
smtp.starttls.assert_called_once()
|
||||||
|
smtp.login.assert_called_once_with("mailer", "secret")
|
||||||
|
smtp.send_message.assert_called_once()
|
||||||
|
message = smtp.send_message.call_args.args[0]
|
||||||
|
self.assertEqual(message["Subject"], "[Vignette] 검토할 회기가 있습니다")
|
||||||
|
self.assertIn("teacher@hs.ac.kr", message["To"])
|
||||||
|
self.assertTrue(message.is_multipart())
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTriggerTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_failed_session_evaluation_still_enqueues_teacher_review_notification(self) -> None:
|
||||||
|
sess = _ended_session()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
sessions.evaluator,
|
||||||
|
"evaluate_session",
|
||||||
|
AsyncMock(side_effect=RuntimeError("engine offline")),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
sessions.session_persistence,
|
||||||
|
"save_session_evaluation",
|
||||||
|
AsyncMock(return_value=True),
|
||||||
|
) as save_evaluation,
|
||||||
|
patch.object(
|
||||||
|
sessions.notifications,
|
||||||
|
"enqueue_session_review_ready",
|
||||||
|
AsyncMock(),
|
||||||
|
) as enqueue_review,
|
||||||
|
):
|
||||||
|
await sessions._generate_and_save_session_evaluation(sess)
|
||||||
|
|
||||||
|
save_evaluation.assert_awaited_once()
|
||||||
|
enqueue_review.assert_awaited_once_with(session_id=sess.session_id)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -48,6 +48,66 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/tickets": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -1339,6 +1399,85 @@ export interface components {
|
||||||
*/
|
*/
|
||||||
status: "ok" | "degraded" | "down";
|
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 */
|
||||||
AdminServiceHealth: {
|
AdminServiceHealth: {
|
||||||
/** Detail */
|
/** Detail */
|
||||||
|
|
@ -2486,6 +2625,11 @@ export interface components {
|
||||||
};
|
};
|
||||||
/** NotificationPreferences */
|
/** NotificationPreferences */
|
||||||
NotificationPreferences: {
|
NotificationPreferences: {
|
||||||
|
/**
|
||||||
|
* Account Approval
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
account_approval: boolean;
|
||||||
/**
|
/**
|
||||||
* Learner Progress
|
* Learner Progress
|
||||||
* @default false
|
* @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: {
|
list_tickets_admin_tickets_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,7 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
||||||
session_done: true,
|
session_done: true,
|
||||||
safety_signal: true,
|
safety_signal: true,
|
||||||
learner_progress: true,
|
learner_progress: true,
|
||||||
|
account_approval: true,
|
||||||
product_news: false,
|
product_news: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -347,6 +348,12 @@ export default function Settings() {
|
||||||
hint: "교수자와 관리자에게만 표시됩니다.",
|
hint: "교수자와 관리자에게만 표시됩니다.",
|
||||||
roles: ["teacher", "admin"],
|
roles: ["teacher", "admin"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "account_approval",
|
||||||
|
label: "가입 승인",
|
||||||
|
hint: "신규 가입 승인 요청이 있으면 알려줍니다.",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "product_news",
|
id: "product_news",
|
||||||
label: "제품 소식",
|
label: "제품 소식",
|
||||||
|
|
|
||||||
|
|
@ -531,7 +531,8 @@
|
||||||
<p class="dg-note">95차 적용(2026-06-29): refactor-governance P2/P3로 M2 one-shot session digest worker 경계를 추가했다. <code>app/services/session_digest_worker.py</code>가 <code>CompressionJob</code>을 Node-compatible <code>GenerateRequest</code>/<code>EngineMessage</code>로 변환하고, 주입형 engine·<code>audit_hook</code>으로 단발 호출한 뒤 local quality gate를 통과한 결과만 <code>session_summary.digest/compressed_by/token_count</code>와 <code>case_profile.case_digest</code>에 idempotent 적용할 수 있게 한다. DB loader는 persisted fallback summary와 client-visible masked transcript만 재구성하며 raw text, evaluator-only turn, CCD, end_state는 prompt에 넣지 않는다. 이는 provider live run, background scheduler, 임상 골든셋 품질평가, 재압축 완료가 아니다. 검증: <code>py -3.11 -X utf8 -B -m pytest -p no:cacheprovider app/test_session_digest_worker.py app/test_session_memory.py -q</code> 25 passed, <code>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</code> 82 passed.</p>
|
<p class="dg-note">95차 적용(2026-06-29): refactor-governance P2/P3로 M2 one-shot session digest worker 경계를 추가했다. <code>app/services/session_digest_worker.py</code>가 <code>CompressionJob</code>을 Node-compatible <code>GenerateRequest</code>/<code>EngineMessage</code>로 변환하고, 주입형 engine·<code>audit_hook</code>으로 단발 호출한 뒤 local quality gate를 통과한 결과만 <code>session_summary.digest/compressed_by/token_count</code>와 <code>case_profile.case_digest</code>에 idempotent 적용할 수 있게 한다. DB loader는 persisted fallback summary와 client-visible masked transcript만 재구성하며 raw text, evaluator-only turn, CCD, end_state는 prompt에 넣지 않는다. 이는 provider live run, background scheduler, 임상 골든셋 품질평가, 재압축 완료가 아니다. 검증: <code>py -3.11 -X utf8 -B -m pytest -p no:cacheprovider app/test_session_digest_worker.py app/test_session_memory.py -q</code> 25 passed, <code>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</code> 82 passed.</p>
|
||||||
<p class="dg-note">96차 적용(2026-06-29): M2 digest worker를 운영자가 명시 실행할 수 있도록 <code>scripts/run-session-digest-worker.py</code> dry-run/apply runner를 추가했다. 기본은 metadata-only dry-run이고, <code>--apply</code>를 줘야 accepted 후보를 DB에 반영한다. runner는 load → DB release → engine generate → short apply 순서로 동작해 LLM 호출 동안 DB transaction을 잡지 않는다. digest 본문은 민감할 수 있어 <code>--show-digest</code>를 명시할 때만 출력한다. 이는 실 provider 장시간 운영 완료가 아니다. 검증: <code>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</code>, <code>py -3.11 -X utf8 scripts\run-session-digest-worker.py --help</code>, M2 focused 25 passed.</p>
|
<p class="dg-note">96차 적용(2026-06-29): M2 digest worker를 운영자가 명시 실행할 수 있도록 <code>scripts/run-session-digest-worker.py</code> dry-run/apply runner를 추가했다. 기본은 metadata-only dry-run이고, <code>--apply</code>를 줘야 accepted 후보를 DB에 반영한다. runner는 load → DB release → engine generate → short apply 순서로 동작해 LLM 호출 동안 DB transaction을 잡지 않는다. digest 본문은 민감할 수 있어 <code>--show-digest</code>를 명시할 때만 출력한다. 이는 실 provider 장시간 운영 완료가 아니다. 검증: <code>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</code>, <code>py -3.11 -X utf8 scripts\run-session-digest-worker.py --help</code>, M2 focused 25 passed.</p>
|
||||||
<p class="dg-note">97차 적용(2026-06-29): <code>SESSION_DIGEST_WORKER_ENABLED=false</code> 기본값을 두고, opt-in일 때만 세션 종료 성공 뒤 digest worker를 background task로 예약한다. scheduler는 persisted fallback row를 load한 뒤 DB connection을 놓고 engine을 호출하며, accepted plan만 짧은 apply transaction으로 반영한다. 이미 <code>compressed_by</code>가 있는 세션은 loader에서 제외하고, apply도 <code>compressed_by IS NULL</code> CAS로 보호하며, fallback summary 재작성 시 compression metadata를 초기화한다. 이는 bounded/default-off scheduler 골격이며, 실 provider 장시간 운영·임상 골든셋 품질평가·재압축 정책 완료가 아니다. 검증: <code>py_compile</code>, runner <code>--help</code>, M2 focused 30 passed.</p>
|
<p class="dg-note">97차 적용(2026-06-29): <code>SESSION_DIGEST_WORKER_ENABLED=false</code> 기본값을 두고, opt-in일 때만 세션 종료 성공 뒤 digest worker를 background task로 예약한다. scheduler는 persisted fallback row를 load한 뒤 DB connection을 놓고 engine을 호출하며, accepted plan만 짧은 apply transaction으로 반영한다. 이미 <code>compressed_by</code>가 있는 세션은 loader에서 제외하고, apply도 <code>compressed_by IS NULL</code> CAS로 보호하며, fallback summary 재작성 시 compression metadata를 초기화한다. 이는 bounded/default-off scheduler 골격이며, 실 provider 장시간 운영·임상 골든셋 품질평가·재압축 정책 완료가 아니다. 검증: <code>py_compile</code>, runner <code>--help</code>, M2 focused 30 passed.</p>
|
||||||
<p class="dg-note">최신 적용(99차, 2026-06-29): 대시보드 SSOT drift gate를 보강했다. <code>scripts/check-dev-dashboard-ssot.py</code>는 현재 카드 상태(<code>done=25</code>, <code>planned=0</code>, <code>doing=0</code>), CHECK 잔여 0, owner board counts(<code>block=2</code>, <code>decide=7</code>, <code>ext=2</code>), 외부 <code>GATE</code> 상세 6행, 결정 탭 7행, 자유연습 F-10 결정 문구, M2 30/87 검증 수치, default-off scheduler/CAS 문구, 실 provider·임상 골든셋·재압축 GATE 보존, stale 82/25/26 passed 및 낡은 결정 8건 문구 제거를 함께 확인한다. 검증: <code>py_compile</code>, <code>py -3.11 -X utf8 scripts\check-dev-dashboard-ssot.py --json</code> PASS. 아래 적용 노트 일부는 historical snapshot이라 당시 통과 수치를 그대로 보존한다.</p>
|
<p class="dg-note">99차 적용(2026-06-29): 대시보드 SSOT drift gate를 보강했다. <code>scripts/check-dev-dashboard-ssot.py</code>는 현재 카드 상태(<code>done=25</code>, <code>planned=0</code>, <code>doing=0</code>), CHECK 잔여 0, owner board counts(<code>block=2</code>, <code>decide=7</code>, <code>ext=2</code>), 외부 <code>GATE</code> 상세 6행, 결정 탭 7행, 자유연습 F-10 결정 문구, M2 30/87 검증 수치, default-off scheduler/CAS 문구, 실 provider·임상 골든셋·재압축 GATE 보존, stale 82/25/26 passed 및 낡은 결정 8건 문구 제거를 함께 확인한다. 검증: <code>py_compile</code>, <code>py -3.11 -X utf8 scripts\check-dev-dashboard-ssot.py --json</code> PASS. 아래 적용 노트 일부는 historical snapshot이라 당시 통과 수치를 그대로 보존한다.</p>
|
||||||
|
<p class="dg-note">100차 적용(2026-06-29): 관리자·교수자 메일링 시스템 1차를 추가했다. 신규 외부 로그인 pending 계정은 <code>account_pending_approval:{user_id}</code>, 회기 종료 후 평가/error record 저장 완료 세션은 <code>session_review_ready:{session_id}</code>, 명시 테스트 메일은 <code>admin_test_email:{uuid}</code> idempotency key로 <code>app.notification_event</code>/<code>app.notification_delivery</code>에 큐잉된다. 메일 HTML은 Vignette 톤앤매너(종이 배경, 세이지 CTA, 8px 카드)를 inline style로 유지하고, 본문에는 축어록·평가 전문을 넣지 않고 <code>/admin/users</code>, <code>/teach/session/:sessionId/review</code>, <code>/admin</code> 딥링크만 제공한다. <code>NOTIFICATION_EMAIL_PROVIDER=smtp</code>와 <code>SMTP_*</code>가 설정된 경우 실제 발송하며, <code>GET /admin/notifications</code>, <code>POST /admin/notifications/process</code>, <code>POST /admin/notifications/test</code>, <code>scripts/run-notification-worker.py</code>로 delivery 상태와 재시도를 운영한다.</p>
|
||||||
<p class="dg-note">20차 적용(2026-06-28): 신규 Google/SAML 사용자는 <code>account_status=pending</code>으로 시작하고 승인 전에는 <code>/pending</code> 안내 화면만 본다. <code>yunchan@twentyoz.kr</code>는 슈퍼 관리자 allowlist로 admin+approved를 받으며, <code>/admin/users</code>는 가입 승인 탭에서 pending 계정을 승인 또는 보류 처리한다.</p>
|
<p class="dg-note">20차 적용(2026-06-28): 신규 Google/SAML 사용자는 <code>account_status=pending</code>으로 시작하고 승인 전에는 <code>/pending</code> 안내 화면만 본다. <code>yunchan@twentyoz.kr</code>는 슈퍼 관리자 allowlist로 admin+approved를 받으며, <code>/admin/users</code>는 가입 승인 탭에서 pending 계정을 승인 또는 보류 처리한다.</p>
|
||||||
<p class="dg-note">21차 적용(2026-06-28): 관리자 페이지 진입권을 기본 역할과 분리해 <code>app_user.admin_access</code>로 저장한다. <code>AUTH_SUPER_ADMIN_EMAILS</code> 기본값은 <code>yunchan@twentyoz.kr</code>, <code>hoonjungkoo@hs.ac.kr</code>이며, 슈퍼 관리자는 학습자·교수자·관리자 공간 전환과 관리자 권한 부여/회수를 할 수 있다. 학생·교수 계정도 <code>admin_access=true</code>면 우측 상단 관리자 진입이 노출된다. 구성 슈퍼 관리자의 권한 회수와 계정 비활성화는 차단한다.</p>
|
<p class="dg-note">21차 적용(2026-06-28): 관리자 페이지 진입권을 기본 역할과 분리해 <code>app_user.admin_access</code>로 저장한다. <code>AUTH_SUPER_ADMIN_EMAILS</code> 기본값은 <code>yunchan@twentyoz.kr</code>, <code>hoonjungkoo@hs.ac.kr</code>이며, 슈퍼 관리자는 학습자·교수자·관리자 공간 전환과 관리자 권한 부여/회수를 할 수 있다. 학생·교수 계정도 <code>admin_access=true</code>면 우측 상단 관리자 진입이 노출된다. 구성 슈퍼 관리자의 권한 회수와 계정 비활성화는 차단한다.</p>
|
||||||
<p class="dg-note">25차 적용(2026-06-28): <code>/admin/users</code>에서 허용 도메인 밖 이메일도 정확한 계정 단위로 강제 등록할 수 있다. Google/SAML/dev-login은 미리 등록된 이메일만 도메인 게이트 예외로 통과시키고, provider 로그인 시 기존 관리 row의 역할·코호트·승인 상태를 이어받는다. 미등록 외부 도메인 로그인은 계속 차단한다.</p>
|
<p class="dg-note">25차 적용(2026-06-28): <code>/admin/users</code>에서 허용 도메인 밖 이메일도 정확한 계정 단위로 강제 등록할 수 있다. Google/SAML/dev-login은 미리 등록된 이메일만 도메인 게이트 예외로 통과시키고, provider 로그인 시 기존 관리 row의 역할·코호트·승인 상태를 이어받는다. 미등록 외부 도메인 로그인은 계속 차단한다.</p>
|
||||||
|
|
@ -929,7 +930,8 @@
|
||||||
<tr><td>Engine session reuse</td><td><code>engine_gateway.test_gateway_model</code></td><td>27 tests OK; shared engine contract, JSON Schema + golden fixture validation, Node.js artifact conformance runner, <code>GenerateResponse</code> response validation, structured payload fallback parser, current-turn prompt split, direct evaluator/live-coach parser ownership, SSE frames/decoder, live session_id reuse, <code>gateway-default</code> 기본 라우팅 sentinel 정규화, missing-user 400 guard, ephemeral close fixed</td></tr>
|
<tr><td>Engine session reuse</td><td><code>engine_gateway.test_gateway_model</code></td><td>27 tests OK; shared engine contract, JSON Schema + golden fixture validation, Node.js artifact conformance runner, <code>GenerateResponse</code> response validation, structured payload fallback parser, current-turn prompt split, direct evaluator/live-coach parser ownership, SSE frames/decoder, live session_id reuse, <code>gateway-default</code> 기본 라우팅 sentinel 정규화, missing-user 400 guard, ephemeral close fixed</td></tr>
|
||||||
<tr><td>P1/H4 masking gate</td><td><code>app/test_pii_masking_eval.py app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py</code></td><td>47 passed; phone/email/RRN 및 한국어 NAME/ORG raw 값이 generate/stream/evaluator payload와 client <code>text_masked</code>에 남지 않음. Optional ko recognizer fake span은 <code>[NAME]</code>/<code>[ORG]</code>로 마스킹되고 같은 문장의 phone은 후단 regex가 처리하며, adapter 실패 시에도 regex fallback이 유지된다. Synthetic ko fixture 15/15 pass, 자연 발화형 이름 라벨·자기소개와 negative control 포함, input/report schema validation, summary-only report(<code>evidence_text_included=false</code>), entity recall 1.0, forbidden substring removal 1.0, unexpected entity violations 0.</td></tr>
|
<tr><td>P1/H4 masking gate</td><td><code>app/test_pii_masking_eval.py app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py</code></td><td>47 passed; phone/email/RRN 및 한국어 NAME/ORG raw 값이 generate/stream/evaluator payload와 client <code>text_masked</code>에 남지 않음. Optional ko recognizer fake span은 <code>[NAME]</code>/<code>[ORG]</code>로 마스킹되고 같은 문장의 phone은 후단 regex가 처리하며, adapter 실패 시에도 regex fallback이 유지된다. Synthetic ko fixture 15/15 pass, 자연 발화형 이름 라벨·자기소개와 negative control 포함, input/report schema validation, summary-only report(<code>evidence_text_included=false</code>), entity recall 1.0, forbidden substring removal 1.0, unexpected entity violations 0.</td></tr>
|
||||||
<tr><td>P2a RBAC/audit/visibility</td><td><code>app.test_rbac_idor</code></td><td>8 tests OK; other learner 403, read_session audit, evaluator-only hidden, teacher session review read allowed while learner worksheet write remains 403</td></tr>
|
<tr><td>P2a RBAC/audit/visibility</td><td><code>app.test_rbac_idor</code></td><td>8 tests OK; other learner 403, read_session audit, evaluator-only hidden, teacher session review read allowed while learner worksheet write remains 403</td></tr>
|
||||||
<tr><td>Admin access delegation</td><td><code>python -m pytest app/test_auth_providers.py app/test_admin_ops.py -q</code> / <code>npm run typecheck</code> / <code>npm run check:api-types</code></td><td>32 backend passed; <code>admin_access</code> and <code>super_admin</code> are exposed through auth/admin DTOs. Super admins can grant/revoke admin page access; configured super admins stay protected. Frontend role switching uses <code>canAccessRole</code>.</td></tr>
|
<tr><td>Admin access delegation</td><td><code>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</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1</code> / <code>npm run check:api-types</code> / <code>npm run typecheck</code> / <code>npm run build</code></td><td>70 backend focused passed + admin/auth/runtime 64 passed + admin E2E 8 passed. 실제 <code>admin</code> role은 학습자·교수자·관리자 공간을 모두 열고, <code>admin_access</code>만 받은 비관리자 계정은 관리자 콘솔 진입권만 얻는다. 관리자 sidebar는 운영 홈/사용자/권한/티켓과 교수자·학습자 작업 공간 링크를 함께 노출한다.</td></tr>
|
||||||
|
<tr><td>Session read-model DB readiness</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code> / <code>https://api-vignette.chanpaca.net/health</code></td><td>2026-06-29 prod 503 원인은 운영 DB의 <code>app.turns.provider_events</code> 컬럼 누락이었다. 운영 DB hotfix 후 <code>/teacher/dashboard</code> code path는 <code>source=database</code>로 복구됐고, <code>db.healthcheck()</code>, runtime table readiness, deploy preflight DB mode가 <code>app.turns.provider_events</code>와 <code>app.session_review_status</code> worksheet 컬럼을 함께 검증한다. Public health는 <code>environment=prod</code>, <code>db=true</code>, <code>engine=true</code>.</td></tr>
|
||||||
<tr><td>Admin usage persistence</td><td><code>python -m pytest app/test_admin_ops.py app/test_runtime_policy.py -q</code> / authenticated local public-API smoke</td><td>26 passed; <code>/admin/usage</code> returns 200 with <code>source=database</code>, <code>durable=true</code>. Cloudflare blocked raw Python public smoke with 1010, so app-level HTTP was verified against <code>127.0.0.1:8001</code> using the same prod process.</td></tr>
|
<tr><td>Admin usage persistence</td><td><code>python -m pytest app/test_admin_ops.py app/test_runtime_policy.py -q</code> / authenticated local public-API smoke</td><td>26 passed; <code>/admin/usage</code> returns 200 with <code>source=database</code>, <code>durable=true</code>. Cloudflare blocked raw Python public smoke with 1010, so app-level HTTP was verified against <code>127.0.0.1:8001</code> using the same prod process.</td></tr>
|
||||||
<tr><td>Synthetic health sampler</td><td><code>record_admin_health_sample()</code>, <code>record-admin-health-sample.py</code>, <code>install-health-sampler-task.ps1</code></td><td>Backend focused 31 passed. Python compile/help passed; PowerShell parser + <code>-PrintOnly</code> passed. Local one-shot appended 5 service rows to <code>app.admin_health_event</code>: status ok, engine_mode claude_cli. This remains sample history, not an SLA claim.</td></tr>
|
<tr><td>Synthetic health sampler</td><td><code>record_admin_health_sample()</code>, <code>record-admin-health-sample.py</code>, <code>install-health-sampler-task.ps1</code></td><td>Backend focused 31 passed. Python compile/help passed; PowerShell parser + <code>-PrintOnly</code> passed. Local one-shot appended 5 service rows to <code>app.admin_health_event</code>: status ok, engine_mode claude_cli. This remains sample history, not an SLA claim.</td></tr>
|
||||||
<tr><td>Health retention/rollup</td><td><code>admin_health_daily_rollup</code>, <code>maintain-admin-health-events.py</code>, <code>/admin/uptime</code></td><td>Backend focused 36 passed. Python compile/help passed. Local dry-run returned <code>rollup_event_count=0</code>, <code>prunable_event_count=0</code>. Uptime summary now combines raw samples with daily rollups when raw rows have been pruned; detailed events stay raw-only.</td></tr>
|
<tr><td>Health retention/rollup</td><td><code>admin_health_daily_rollup</code>, <code>maintain-admin-health-events.py</code>, <code>/admin/uptime</code></td><td>Backend focused 36 passed. Python compile/help passed. Local dry-run returned <code>rollup_event_count=0</code>, <code>prunable_event_count=0</code>. Uptime summary now combines raw samples with daily rollups when raw rows have been pruned; detailed events stay raw-only.</td></tr>
|
||||||
|
|
@ -953,15 +955,15 @@
|
||||||
<tr><td>Python compile</td><td><code>python -m compileall app engine_gateway</code></td><td>Passed</td></tr>
|
<tr><td>Python compile</td><td><code>python -m compileall app engine_gateway</code></td><td>Passed</td></tr>
|
||||||
<tr><td>Phase 3 artifact gates</td><td><code>python scripts\check-phase3-artifacts.py --help</code> + <code>pytest app/test_phase3_artifact_checker.py -q</code></td><td>Passed; checker now enforces CSV enums, KPI metric required fields/status, approved export PII/agreement/consent/withdrawal/file-hash gates. Actual pilot evidence still external.</td></tr>
|
<tr><td>Phase 3 artifact gates</td><td><code>python scripts\check-phase3-artifacts.py --help</code> + <code>pytest app/test_phase3_artifact_checker.py -q</code></td><td>Passed; checker now enforces CSV enums, KPI metric required fields/status, approved export PII/agreement/consent/withdrawal/file-hash gates. Actual pilot evidence still external.</td></tr>
|
||||||
<tr><td>Web build</td><td><code>npm run build</code></td><td>Passed</td></tr>
|
<tr><td>Web build</td><td><code>npm run build</code></td><td>Passed</td></tr>
|
||||||
<tr><td>Pages production deploy</td><td><code>wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true</code></td><td>2026-06-29 manual deploy from committed source; preview <code>https://eb2ed257.vignette-b1q.pages.dev</code></td></tr>
|
<tr><td>Pages production deploy</td><td><code>wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true</code></td><td>2026-06-29 admin IA/readiness fix deploy from current working tree; preview <code>https://c67ca04a.vignette-b1q.pages.dev</code></td></tr>
|
||||||
<tr><td>Custom domain assets</td><td><code>https://vignette.chanpaca.net/login?deploy=20260629</code></td><td>2026-06-29 recheck: 200, <code>index-9KEDuuBQ.js</code>, <code>index-DC83qEpU.css</code></td></tr>
|
<tr><td>Custom domain assets</td><td><code>https://vignette.chanpaca.net/login?admin-fix=20260629-1</code></td><td>2026-06-29 recheck: 200, <code>index-Ba7CXeH_.js</code>, <code>index-DC83qEpU.css</code></td></tr>
|
||||||
<tr><td>Legacy Live2D routes</td><td><code>/live2d/mao/* / /live2d/haru/* / /live2d/live2dcubismcore.min.js</code></td><td>404</td></tr>
|
<tr><td>Legacy Live2D routes</td><td><code>/live2d/mao/* / /live2d/haru/* / /live2d/live2dcubismcore.min.js</code></td><td>404</td></tr>
|
||||||
<tr><td>Compose template</td><td><code>docker compose -f infra\docker-compose.yml config --quiet</code></td><td>Template path is valid with dummy required env. API build context now uses repo root; stale <code>rag.server</code> sidecar removed.</td></tr>
|
<tr><td>Compose template</td><td><code>docker compose -f infra\docker-compose.yml config --quiet</code></td><td>Template path is valid with dummy required env. API build context now uses repo root; stale <code>rag.server</code> sidecar removed.</td></tr>
|
||||||
<tr><td>Docker image smoke</td><td><code>docker build -f apps/api/Dockerfile .</code> / <code>docker run ... python -c "import app.main"</code> / <code>docker build -f apps/web/Dockerfile apps/web</code></td><td>API/Web image build and API import smoke pass after packaging cleanup. Web build uses npm lockfile and ignores host <code>node_modules</code>; API image excludes local <code>.env</code> files.</td></tr>
|
<tr><td>Docker image smoke</td><td><code>docker build -f apps/api/Dockerfile .</code> / <code>docker run ... python -c "import app.main"</code> / <code>docker build -f apps/web/Dockerfile apps/web</code></td><td>API/Web image build and API import smoke pass after packaging cleanup. Web build uses npm lockfile and ignores host <code>node_modules</code>; API image excludes local <code>.env</code> files.</td></tr>
|
||||||
<tr><td>Deploy preflight</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code></td><td>Passed: exact-pinned API requirements, live coaching <code>data/kb</code> source pack, env template keys. DB mode can additionally check app-role DSN with <code>--require-app-role</code>.</td></tr>
|
<tr><td>Deploy preflight</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code> / DB mode with local <code>DATABASE_URL</code></td><td>Passed: exact-pinned API requirements, live coaching <code>data/kb</code> source pack, env template keys, and DB readiness (<code>current_user=vignette</code>). DB mode can additionally check app-role DSN with <code>--require-app-role</code> and now verifies session read-model columns including <code>app.turns.provider_events</code> plus worksheet review columns.</td></tr>
|
||||||
<tr><td>Fresh compose smoke</td><td><code>docker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build</code> + proxy <code>/api/health</code></td><td>Passed with dummy production-safe env: API healthy, DB healthy, web/proxy up, <code>http://localhost:18080/api/health</code> 200 with <code>db:true</code>, <code>engine:true</code>, <code>engine_mode:"claude_cli"</code>. Smoke volumes/network removed after run.</td></tr>
|
<tr><td>Fresh compose smoke</td><td><code>docker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build</code> + proxy <code>/api/health</code></td><td>Passed with dummy production-safe env: API healthy, DB healthy, web/proxy up, <code>http://localhost:18080/api/health</code> 200 with <code>db:true</code>, <code>engine:true</code>, <code>engine_mode:"claude_cli"</code>. Smoke volumes/network removed after run.</td></tr>
|
||||||
<tr><td>Actual deployment env</td><td><code>infra/.env</code> owner-secret fill-in</td><td>Remaining external step: deployment target must provide real <code>APP_DB_PASSWORD</code>, OAuth client id/secret, <code>OPENAI_API_KEY</code>, <code>SESSION_SECRET</code>, and production-safe engine/voice flags. Current local stray values such as <code>ENGINE_MODE=claude_p</code> and prod sample TTS must not be copied.</td></tr>
|
<tr><td>Actual deployment env</td><td><code>infra/.env</code> owner-secret fill-in</td><td>Remaining external step: deployment target must provide real <code>APP_DB_PASSWORD</code>, OAuth client id/secret, <code>OPENAI_API_KEY</code>, <code>SESSION_SECRET</code>, and production-safe engine/voice flags. Current local stray values such as <code>ENGINE_MODE=claude_p</code> and prod sample TTS must not be copied.</td></tr>
|
||||||
<tr><td>Focused E2E</td><td><code>admin + db-persistence + voice-success</code></td><td>10 passed</td></tr>
|
<tr><td>Focused E2E</td><td><code>admin + db-persistence + voice-success</code></td><td>Latest 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.</td></tr>
|
||||||
<tr><td>Admin manage-users</td><td><code>admin.spec.ts --grep manage real server-known users</code></td><td>desktop/mobile 2 passed</td></tr>
|
<tr><td>Admin manage-users</td><td><code>admin.spec.ts --grep manage real server-known users</code></td><td>desktop/mobile 2 passed</td></tr>
|
||||||
<tr><td>Full E2E baseline</td><td><code>PLAYWRIGHT_PORT=5174 npm run e2e</code></td><td>2026-06-27/28 기준선 113 passed</td></tr>
|
<tr><td>Full E2E baseline</td><td><code>PLAYWRIGHT_PORT=5174 npm run e2e</code></td><td>2026-06-27/28 기준선 113 passed</td></tr>
|
||||||
<tr><td>Public auth discovery</td><td><code>E2E_PUBLIC_AUTH=1 npx playwright test --list --project=chromium-public-auth</code></td><td>2 tests listed</td></tr>
|
<tr><td>Public auth discovery</td><td><code>E2E_PUBLIC_AUTH=1 npx playwright test --list --project=chromium-public-auth</code></td><td>2 tests listed</td></tr>
|
||||||
|
|
@ -970,8 +972,8 @@
|
||||||
<tr><td>Persona auth boundary</td><td><code>GET /personas</code></td><td>verified local/public unauth 401</td></tr>
|
<tr><td>Persona auth boundary</td><td><code>GET /personas</code></td><td>verified local/public unauth 401</td></tr>
|
||||||
<tr><td>Public login</td><td><code>auth.spec.ts --grep public login</code></td><td>1 passed</td></tr>
|
<tr><td>Public login</td><td><code>auth.spec.ts --grep public login</code></td><td>1 passed</td></tr>
|
||||||
<tr><td>Public runtime scripts</td><td><code>start/watch/install-public-runtime*.ps1</code></td><td>parser OK; watchdog check-only healthy. Default public checks exclude not-yet-live <code>api-vnet.18ka.net</code>; add future domains explicitly with <code>-AdditionalPublicHealthUrls</code>.</td></tr>
|
<tr><td>Public runtime scripts</td><td><code>start/watch/install-public-runtime*.ps1</code></td><td>parser OK; watchdog check-only healthy. Default public checks exclude not-yet-live <code>api-vnet.18ka.net</code>; add future domains explicitly with <code>-AdditionalPublicHealthUrls</code>.</td></tr>
|
||||||
<tr><td>Public API health</td><td><code>https://api-vignette.chanpaca.net/health</code></td><td>prod, db true, engine true</td></tr>
|
<tr><td>Public API health</td><td><code>http://127.0.0.1:8001/health</code> / <code>https://api-vignette.chanpaca.net/health</code></td><td>2026-06-29 after public API restart: both return <code>status=ok</code>, <code>environment=prod</code>, <code>db=true</code>, <code>engine=true</code>, <code>engine_mode=claude_cli</code>. Unauthenticated protected routes such as <code>/personas</code> and <code>/teacher/dashboard</code> return 401, not 503.</td></tr>
|
||||||
<tr><td>Public/local/Tailnet login recovery</td><td><code>https://vignette.chanpaca.net/login</code> / <code>https://api-vignette.chanpaca.net/health</code> / <code>https://alpaca-home.taile93291.ts.net/login</code></td><td>2026-06-29 public recheck: public login 200 (<code>assets/index-9KEDuuBQ.js</code>, <code>assets/index-DC83qEpU.css</code>), public API health <code>status=ok</code>, <code>environment=prod</code>, <code>db=true</code>, <code>engine=true</code>, <code>engine_mode=claude_cli</code>, unauth public <code>/personas</code> 401. Earlier local/Tailnet checks remain recorded separately; vnet DNS A records are still 0.</td></tr>
|
<tr><td>Public/local/Tailnet login recovery</td><td><code>https://vignette.chanpaca.net/login</code> / <code>https://api-vignette.chanpaca.net/health</code> / <code>https://alpaca-home.taile93291.ts.net/login</code></td><td>2026-06-29 public recheck: public login 200 (<code>assets/index-Ba7CXeH_.js</code>, <code>assets/index-DC83qEpU.css</code>), public API health <code>status=ok</code>, <code>environment=prod</code>, <code>db=true</code>, <code>engine=true</code>, <code>engine_mode=claude_cli</code>, unauth public <code>/personas</code> 401. Earlier local/Tailnet checks remain recorded separately; vnet DNS A records are still 0.</td></tr>
|
||||||
<tr><td>Local 5175 login</td><td><code>PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 auth.spec.ts</code></td><td>desktop/mobile passed</td></tr>
|
<tr><td>Local 5175 login</td><td><code>PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 auth.spec.ts</code></td><td>desktop/mobile passed</td></tr>
|
||||||
<tr><td>Learner/readiness E2E</td><td><code>learner.spec.ts + readiness.spec.ts desktop/mobile</code></td><td>14 passed</td></tr>
|
<tr><td>Learner/readiness E2E</td><td><code>learner.spec.ts + readiness.spec.ts desktop/mobile</code></td><td>14 passed</td></tr>
|
||||||
<tr><td>Learner screenshots</td><td><code>learn-empty-desktop.png / learn-empty-mobile-compact.png</code></td><td>document overflow 0, empty history visible</td></tr>
|
<tr><td>Learner screenshots</td><td><code>learn-empty-desktop.png / learn-empty-mobile-compact.png</code></td><td>document overflow 0, empty history visible</td></tr>
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ vignette/
|
||||||
|
|
||||||
`GET /health`는 liveness + DB readiness(`db.healthcheck()`) + 엔진 게이트웨이 readiness
|
`GET /health`는 liveness + DB readiness(`db.healthcheck()`) + 엔진 게이트웨이 readiness
|
||||||
(`engine_client.health_detail()`)를 합쳐 `{"status": "ok|degraded", db, engine, engine_mode, ...}`를 반환한다.
|
(`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`
|
### 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` 자료를 교수자 읽기 전용으로 표시하고,
|
- `/teach/session/:sessionId/review` 화면은 같은 `GET /sessions/{id}/review` 자료를 교수자 읽기 전용으로 표시하고,
|
||||||
검토 메모 저장은 위 teacher endpoint로 분리한다.
|
검토 메모 저장은 위 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를 서버에서
|
- `GET /share/session/{token}` — 인증 없이 접근 가능한 unfurl HTML. Open Graph/Twitter Card/JSON-LD를 서버에서
|
||||||
직접 내려 URL만 전달해도 카카오톡·Slack·메일·AI 브라우저가 제목/요약/썸네일을 읽을 수 있게 한다.
|
직접 내려 URL만 전달해도 카카오톡·Slack·메일·AI 브라우저가 제목/요약/썸네일을 읽을 수 있게 한다.
|
||||||
|
|
@ -512,15 +534,15 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/login` | Login (dev-login 경로 포함) | 공개 |
|
| `/login` | Login (dev-login 경로 포함) | 공개 |
|
||||||
| `/pending` | PendingApproval(승인 대기/보류 안내) | 인증됨, approved 전용 제한 화면 |
|
| `/pending` | PendingApproval(승인 대기/보류 안내) | 인증됨, approved 전용 제한 화면 |
|
||||||
| `/learn` | LearnerHome(대시보드: 학습 요약, 최근 회기 리캡, AI 코치) | learner |
|
| `/learn` | LearnerHome(대시보드: 학습 요약, 최근 회기 리캡, AI 코치) | learner/admin(learner 관점) |
|
||||||
| `/learn/practice` | LearnerHome(연습 대상 선택·새 회기 시작) | learner |
|
| `/learn/practice` | LearnerHome(연습 대상 선택·새 회기 시작) | learner/admin(learner 관점) |
|
||||||
| `/learn/history` | LearnerHome(회기 기록·보관/복원·리뷰 진입) | learner |
|
| `/learn/history` | LearnerHome(회기 기록·보관/복원·리뷰 진입) | learner/admin(learner 관점) |
|
||||||
| `/learn/session/:sessionId` | Session(상담 화면) | learner |
|
| `/learn/session/:sessionId` | Session(상담 화면) | learner/admin(learner 관점) |
|
||||||
| `/learn/session/:sessionId/review` | SessionReview(회기 리뷰) | learner |
|
| `/learn/session/:sessionId/review` | SessionReview(회기 리뷰) | learner/admin(learner 관점) |
|
||||||
| `/learn/avatar-expressions` | AvatarExpressionLab | learner |
|
| `/learn/avatar-expressions` | AvatarExpressionLab | learner/admin(learner 관점) |
|
||||||
| `/teach` | Professor(교수자 대시보드) | teacher |
|
| `/teach` | Professor(교수자 대시보드) | teacher/admin |
|
||||||
| `/teach/personas` | PersonaStudio(페르소나 저작·검수) | teacher/admin |
|
| `/teach/personas` | PersonaStudio(페르소나 저작·검수) | teacher/admin |
|
||||||
| `/teach/session/:sessionId/review` | SessionReview(교수자 읽기 전용 회기 검토) | teacher |
|
| `/teach/session/:sessionId/review` | SessionReview(교수자 읽기 전용 회기 검토) | teacher/admin |
|
||||||
| `/admin` | Admin(운영 홈) | admin |
|
| `/admin` | Admin(운영 홈) | admin |
|
||||||
| `/admin/users` | Admin(사용자 관리) | admin |
|
| `/admin/users` | Admin(사용자 관리) | admin |
|
||||||
| `/admin/access` | 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.
|
- **공개 공유 카드** `app.session_share_link` — `session_id` 단위 공개 토큰 해시와 sanitized preview payload.
|
||||||
RLS는 학습자 본인 생성/폐기와 teacher/admin 열람, public route의 AI 컨텍스트 조회만 허용한다. 원문 축어록을
|
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`
|
- **운영 콘솔** `app.admin_health_event` / `app.admin_health_daily_rollup` — 관리자 `/admin/health`
|
||||||
조회 시점 또는 `scripts/record-admin-health-sample.py` synthetic sampler 실행 시점의 서비스별 원시
|
조회 시점 또는 `scripts/record-admin-health-sample.py` synthetic sampler 실행 시점의 서비스별 원시
|
||||||
헬스 샘플은 `app.admin_health_event`에 남긴다. `scripts/maintain-admin-health-events.py`는 명시
|
헬스 샘플은 `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`),
|
서버 로그에 남긴다. 프론트는 `token_exchange_failed`, `invalid_state`, provider error(`access_denied`/`provider_error`),
|
||||||
identity claim 실패를 구분하고 실패 reason code를 화면에 함께 표시한다.
|
identity claim 실패를 구분하고 실패 reason code를 화면에 함께 표시한다.
|
||||||
- 역할은 `AUTH_TEACHER_EMAILS`/`AUTH_ADMIN_EMAILS` email allowlist로 1차 판정한다.
|
- 역할은 `AUTH_TEACHER_EMAILS`/`AUTH_ADMIN_EMAILS` email allowlist로 1차 판정한다.
|
||||||
`admin_access`는 기본 역할과 별도인 관리자 콘솔 진입 권한이며, 슈퍼 관리자만 `/admin/users`에서
|
실제 `admin` 역할 사용자는 관리자 콘솔, 교수자 공간, 학습자 공간에 모두 접근할 수 있다.
|
||||||
부여·회수할 수 있다. `AUTH_SUPER_ADMIN_EMAILS`는 항상 관리자 콘솔 접근, 학습자·교수자 공간 접근,
|
`admin_access`는 기본 역할과 별도인 관리자 콘솔 진입 권한이며, 비관리자 계정에 학습자·교수자
|
||||||
|
공간 접근권을 추가하지 않는다. 슈퍼 관리자만 `/admin/users`에서 `admin_access`를 부여·회수할 수 있다.
|
||||||
|
`AUTH_SUPER_ADMIN_EMAILS`는 항상 관리자 콘솔 접근, 학습자·교수자 공간 접근,
|
||||||
approved 상태를 부여하는 신뢰 루트다(기본 `yunchan@twentyoz.kr`, `hoonjungkoo@hs.ac.kr`). 코호트는
|
approved 상태를 부여하는 신뢰 루트다(기본 `yunchan@twentyoz.kr`, `hoonjungkoo@hs.ac.kr`). 코호트는
|
||||||
`AUTH_EMAIL_COHORT_MAP`과 `AUTH_DOMAIN_COHORT_MAP` 설정, SAML fixture의 `cohort` claim을 합쳐
|
`AUTH_EMAIL_COHORT_MAP`과 `AUTH_DOMAIN_COHORT_MAP` 설정, SAML fixture의 `cohort` claim을 합쳐
|
||||||
`cohort_ids`로 세션에 저장한다. 관리 사용자 `app_user.external_id`는 provider subject 기반
|
`cohort_ids`로 세션에 저장한다. 관리 사용자 `app_user.external_id`는 provider subject 기반
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,8 @@ npm install
|
||||||
| `EVALUATOR_SEMANTIC_CACHE_ENABLED` | `true` | fast/deep evaluator structured 결과 인메모리 캐시 활성화. 원문 prompt/completion은 저장하지 않음 |
|
| `EVALUATOR_SEMANTIC_CACHE_ENABLED` | `true` | fast/deep evaluator structured 결과 인메모리 캐시 활성화. 원문 prompt/completion은 저장하지 않음 |
|
||||||
| `EVALUATOR_SEMANTIC_CACHE_TTL_SECONDS` | `900` | evaluator cache TTL(초). 0 이하면 비활성 |
|
| `EVALUATOR_SEMANTIC_CACHE_TTL_SECONDS` | `900` | evaluator cache TTL(초). 0 이하면 비활성 |
|
||||||
| `EVALUATOR_SEMANTIC_CACHE_MAX_ENTRIES` | `256` | evaluator cache LRU 최대 엔트리 수. 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`보다 우선한다. 일회성 오버라이드에 쓸 수 있다.
|
> 참고: 프로세스 환경변수(`$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`로 생성된다.
|
- dev-login 사용자는 로컬/E2E 흐름 유지를 위해 `account_status=approved`로 생성된다.
|
||||||
- Google/SAML 신규 사용자는 기본적으로 `account_status=pending`이며, 승인 전에는 `/pending` 화면만 볼 수 있다.
|
- Google/SAML 신규 사용자는 기본적으로 `account_status=pending`이며, 승인 전에는 `/pending` 화면만 볼 수 있다.
|
||||||
관리자 콘솔 `/admin/users`의 가입 승인 탭에서 `approved`로 바꾸면 역할 홈에 접근한다.
|
관리자 콘솔 `/admin/users`의 가입 승인 탭에서 `approved`로 바꾸면 역할 홈에 접근한다.
|
||||||
|
DB가 연결되어 있으면 pending 생성 시 `app.notification_event`/`app.notification_delivery`에 가입 승인
|
||||||
|
메일 큐가 생긴다. `NOTIFICATION_EMAIL_PROVIDER=disabled`인 로컬 기본값에서는 실제 메일은 발송하지 않는다.
|
||||||
- 신규 사용자 또는 온보딩 미완료 사용자는 로그인 직후 `/onboarding`에서 닉네임, 자기소개,
|
- 신규 사용자 또는 온보딩 미완료 사용자는 로그인 직후 `/onboarding`에서 닉네임, 자기소개,
|
||||||
선택 아바타 이미지, 이름, 소속, 학과, 학년/직위, 전화번호, 주소/수령지와 약관·개인정보
|
선택 아바타 이미지, 이름, 소속, 학과, 학년/직위, 전화번호, 주소/수령지와 약관·개인정보
|
||||||
동의를 저장해야 역할 홈으로 이동한다. 학습자 회기 시작은 온보딩 완료와 동의가 모두 있어야 한다.
|
동의를 저장해야 역할 홈으로 이동한다. 학습자 회기 시작은 온보딩 완료와 동의가 모두 있어야 한다.
|
||||||
|
|
@ -277,7 +281,24 @@ C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe scripts\mainta
|
||||||
> 단, 관리자가 `/admin/users`에 미리 등록한 정확한 이메일은 도메인 밖이어도 로그인할 수 있다.
|
> 단, 관리자가 `/admin/users`에 미리 등록한 정확한 이메일은 도메인 밖이어도 로그인할 수 있다.
|
||||||
> 미등록 외부 도메인은 계속 `403 email domain is not allowed`.
|
> 미등록 외부 도메인은 계속 `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의 `curl`은 `Invoke-WebRequest` 별칭이라 JSON 본문·쿠키 다루기가 번거롭다.
|
||||||
PowerShell에서는 `Invoke-RestMethod`가 가장 깔끔하다.
|
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
|
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`로 보낸다.
|
Windows에서 따옴표 이스케이프 사고를 피하려면 본문을 파일에 넣고 `--data-binary @file`로 보낸다.
|
||||||
(PowerShell에서는 반드시 `curl.exe`라고 적어 별칭이 아닌 실제 curl을 호출한다.)
|
(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
|
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)에서
|
`apps/web`의 로그인 화면(`Login.tsx`)에 dev-login 경로가 있다. 웹을 띄운 상태(5173)에서
|
||||||
프록시를 통해 `/api/auth/dev-login`으로 동일하게 동작한다.
|
프록시를 통해 `/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는 공개 도메인
|
> 로컬/Tailnet 테스트는 dev-login을 사용한다. Google OAuth는 공개 도메인
|
||||||
> `https://vignette.chanpaca.net`에서만 실제 계정 흐름으로 검증한다.
|
> `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`는 라이브 코칭의
|
`data/kb/live_coaching_workbook_0615.json`와 `data/kb/live_coaching_sources/*.json`는 라이브 코칭의
|
||||||
기본 근거 source pack이다. API가 DB와 연결된 상태라면 관리자 dev-login 쿠키로 같은 자료를 RAG KB에도
|
기본 근거 source pack이다. API가 DB와 연결된 상태라면 관리자 dev-login 쿠키로 같은 자료를 RAG KB에도
|
||||||
|
|
|
||||||
|
|
@ -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): 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 외부 거버넌스**
|
분류: **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까지 만들었다. 콘텐츠 정의는 임상팀(구훈정·어유경) 소유라 코드는 구조를 선제 구축하되 임상 문안과 평가기준은 외부 정의로 받는다.
|
> **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] **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] **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] **학습자·교수자·운영·페르소나 스튜디오 레이아웃 정렬** — (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] **회기 아카이브 저장/복원 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] **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.**
|
- [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.**
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
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"]
|
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.
|
# 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_API_KEY=
|
||||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
VIGNETTE_VOICE_POC_SAMPLE_TTS=false
|
VIGNETTE_VOICE_POC_SAMPLE_TTS=false
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,49 @@ CREATE INDEX IF NOT EXISTS idx_support_ticket_parent
|
||||||
ON app.support_ticket(parent_ticket_id)
|
ON app.support_ticket(parent_ticket_id)
|
||||||
WHERE parent_ticket_id IS NOT NULL;
|
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 (
|
CREATE TABLE IF NOT EXISTS app.learner_prepost_measure (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE CASCADE,
|
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
|
ON app.support_ticket FOR DELETE
|
||||||
USING (app.current_role_name() = 'admin');
|
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;
|
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_select ON app.learner_prepost_measure;
|
||||||
DROP POLICY IF EXISTS p_learner_prepost_measure_insert ON app.learner_prepost_measure;
|
DROP POLICY IF EXISTS p_learner_prepost_measure_insert ON app.learner_prepost_measure;
|
||||||
|
|
|
||||||
|
|
@ -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"}}'
|
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"]}'
|
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}
|
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:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.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 (
|
EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'app'
|
WHERE table_schema = 'app'
|
||||||
AND table_name = 'persona_card'
|
AND table_name = 'persona_card'
|
||||||
AND column_name = 'triggers'
|
AND column_name = 'triggers'
|
||||||
) AS has_persona_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 (
|
EXISTS (
|
||||||
SELECT 1 FROM pg_policies
|
SELECT 1 FROM pg_policies
|
||||||
WHERE schemaname = 'app'
|
WHERE schemaname = 'app'
|
||||||
|
|
@ -245,7 +274,15 @@ async def check_database(database_url: str, *, require_app_role: bool) -> Check:
|
||||||
"has_session_evaluation",
|
"has_session_evaluation",
|
||||||
"has_live_coach_events",
|
"has_live_coach_events",
|
||||||
"has_session_share_link",
|
"has_session_share_link",
|
||||||
|
"has_notification_event",
|
||||||
|
"has_notification_delivery",
|
||||||
|
"has_sessions",
|
||||||
|
"has_turns",
|
||||||
|
"has_session_review_status",
|
||||||
"has_persona_triggers",
|
"has_persona_triggers",
|
||||||
|
"has_turn_provider_events",
|
||||||
|
"has_session_review_worksheet_columns",
|
||||||
|
"has_notification_delivery_queue_index",
|
||||||
"has_session_write_policies",
|
"has_session_write_policies",
|
||||||
)
|
)
|
||||||
if not row[key]
|
if not row[key]
|
||||||
|
|
|
||||||
44
scripts/run-notification-worker.py
Normal file
44
scripts/run-notification-worker.py
Normal file
|
|
@ -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())
|
||||||
Loading…
Add table
Add a link
Reference in a new issue