메일 알림 시스템 추가

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

View file

@ -89,6 +89,8 @@ vignette/
`GET /health`는 liveness + DB readiness(`db.healthcheck()`) + 엔진 게이트웨이 readiness
(`engine_client.health_detail()`)를 합쳐 `{"status": "ok|degraded", db, engine, engine_mode, ...}`를 반환한다.
DB readiness는 auth/admin 테이블뿐 아니라 세션 read-model 핵심 테이블·컬럼
(`app.sessions`, `app.turns.provider_events`, `app.session_review_status` worksheet 컬럼)을 함께 확인한다.
### 2.2 턴 오케스트레이터 — `app/services/orchestrator.py`
@ -378,7 +380,27 @@ session lifecycle을 유지하며, future Node read API는 이 read-model contra
- `/teach/session/:sessionId/review` 화면은 같은 `GET /sessions/{id}/review` 자료를 교수자 읽기 전용으로 표시하고,
검토 메모 저장은 위 teacher endpoint로 분리한다.
### 2.9.2 공개 공유·검색 메타 — `app/routes/share.py`
### 2.9.2 운영 메일 알림 — `app/services/notifications.py`
- 가입 승인 알림: Google/SAML 신규 사용자가 `account_status=pending`으로 세션을 만들면
`account_pending_approval:{user_id}` idempotency key로 `app.notification_event`를 만들고,
슈퍼 관리자·관리자 콘솔 접근권자 중 `account_approval` 알림을 켠 수신자에게 메일 delivery를 큐잉한다.
- 회기 검토 알림: 회기 종료 후 `app.session_evaluation` 저장이 완료되면
`session_review_ready:{session_id}` idempotency key로 담당 코호트 교수자와 관리자에게
`/teach/session/{sessionId}/review` 딥링크 메일을 큐잉한다. 평가 생성이 실패해도 error record가 저장되면
교수자 수동 검토가 필요하므로 알림은 생성된다.
- 메일은 업무 상태의 원본이 아니다. 승인 상태는 `app.app_user.account_status`, 교수자 검토 상태는
`app.session_review_status`, 전송 상태는 `app.notification_delivery`가 각각 원본이다.
- SMTP 발송은 `NOTIFICATION_EMAIL_PROVIDER=smtp``SMTP_*` env가 있을 때만 수행한다. provider가
`disabled`면 이벤트/큐 구조는 유지되고 실제 발송은 worker 또는 관리자 처리 API 실행 시 skipped로 남는다.
- 메일 HTML은 Vignette 토큰 톤(종이 배경, 세이지-틸 CTA, 8px radius)을 inline style로 재현한다. 메일 본문에는
축어록, 평가 전문, 민감한 심리 상태를 넣지 않고, 로그인 후 앱 화면에서만 확인하게 한다.
- 운영 API: `GET /admin/notifications`는 최근 delivery와 queued/failed/sent/skipped 카운트를 반환하고,
`POST /admin/notifications/process` 또는 `scripts/run-notification-worker.py`는 큐를 한 번 drain한다.
`POST /admin/notifications/test`는 관리자 수신자에게 `admin_test_email:{uuid}` 테스트 이벤트를 만들고
같은 발송 큐로 즉시 처리한다.
### 2.9.3 공개 공유·검색 메타 — `app/routes/share.py`
- `GET /share/session/{token}` — 인증 없이 접근 가능한 unfurl HTML. Open Graph/Twitter Card/JSON-LD를 서버에서
직접 내려 URL만 전달해도 카카오톡·Slack·메일·AI 브라우저가 제목/요약/썸네일을 읽을 수 있게 한다.
@ -512,15 +534,15 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
|---|---|---|
| `/login` | Login (dev-login 경로 포함) | 공개 |
| `/pending` | PendingApproval(승인 대기/보류 안내) | 인증됨, approved 전용 제한 화면 |
| `/learn` | LearnerHome(대시보드: 학습 요약, 최근 회기 리캡, AI 코치) | learner |
| `/learn/practice` | LearnerHome(연습 대상 선택·새 회기 시작) | learner |
| `/learn/history` | LearnerHome(회기 기록·보관/복원·리뷰 진입) | learner |
| `/learn/session/:sessionId` | Session(상담 화면) | learner |
| `/learn/session/:sessionId/review` | SessionReview(회기 리뷰) | learner |
| `/learn/avatar-expressions` | AvatarExpressionLab | learner |
| `/teach` | Professor(교수자 대시보드) | teacher |
| `/learn` | LearnerHome(대시보드: 학습 요약, 최근 회기 리캡, AI 코치) | learner/admin(learner 관점) |
| `/learn/practice` | LearnerHome(연습 대상 선택·새 회기 시작) | learner/admin(learner 관점) |
| `/learn/history` | LearnerHome(회기 기록·보관/복원·리뷰 진입) | learner/admin(learner 관점) |
| `/learn/session/:sessionId` | Session(상담 화면) | learner/admin(learner 관점) |
| `/learn/session/:sessionId/review` | SessionReview(회기 리뷰) | learner/admin(learner 관점) |
| `/learn/avatar-expressions` | AvatarExpressionLab | learner/admin(learner 관점) |
| `/teach` | Professor(교수자 대시보드) | teacher/admin |
| `/teach/personas` | PersonaStudio(페르소나 저작·검수) | teacher/admin |
| `/teach/session/:sessionId/review` | SessionReview(교수자 읽기 전용 회기 검토) | teacher |
| `/teach/session/:sessionId/review` | SessionReview(교수자 읽기 전용 회기 검토) | teacher/admin |
| `/admin` | Admin(운영 홈) | admin |
| `/admin/users` | Admin(사용자 관리) | admin |
| `/admin/access` | Admin(접근 권한) | admin |
@ -600,6 +622,10 @@ DB는 PostgreSQL 16 + pgvector(단일 SoR). 초기화 SQL은 `infra/db/init/`에
- **공개 공유 카드** `app.session_share_link``session_id` 단위 공개 토큰 해시와 sanitized preview payload.
RLS는 학습자 본인 생성/폐기와 teacher/admin 열람, public route의 AI 컨텍스트 조회만 허용한다. 원문 축어록을
저장하지 않는다.
- **운영 메일 알림** `app.notification_event` / `app.notification_delivery` — 가입 승인 요청과 회기 검토 요청을
이벤트와 수신자별 delivery로 분리해 저장한다. `idempotency_key`가 중복 메일을 막고, delivery는
`queued/sending/sent/failed/skipped` 상태와 시도 횟수, provider message id, 마지막 오류만 저장한다.
메일 본문 HTML이나 회기 축어록은 DB에 복제하지 않는다. RLS는 관리자 전체 처리만 허용한다.
- **운영 콘솔** `app.admin_health_event` / `app.admin_health_daily_rollup` — 관리자 `/admin/health`
조회 시점 또는 `scripts/record-admin-health-sample.py` synthetic sampler 실행 시점의 서비스별 원시
헬스 샘플은 `app.admin_health_event`에 남긴다. `scripts/maintain-admin-health-events.py`는 명시
@ -668,8 +694,10 @@ DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`):
서버 로그에 남긴다. 프론트는 `token_exchange_failed`, `invalid_state`, provider error(`access_denied`/`provider_error`),
identity claim 실패를 구분하고 실패 reason code를 화면에 함께 표시한다.
- 역할은 `AUTH_TEACHER_EMAILS`/`AUTH_ADMIN_EMAILS` email allowlist로 1차 판정한다.
`admin_access`는 기본 역할과 별도인 관리자 콘솔 진입 권한이며, 슈퍼 관리자만 `/admin/users`에서
부여·회수할 수 있다. `AUTH_SUPER_ADMIN_EMAILS`는 항상 관리자 콘솔 접근, 학습자·교수자 공간 접근,
실제 `admin` 역할 사용자는 관리자 콘솔, 교수자 공간, 학습자 공간에 모두 접근할 수 있다.
`admin_access`는 기본 역할과 별도인 관리자 콘솔 진입 권한이며, 비관리자 계정에 학습자·교수자
공간 접근권을 추가하지 않는다. 슈퍼 관리자만 `/admin/users`에서 `admin_access`를 부여·회수할 수 있다.
`AUTH_SUPER_ADMIN_EMAILS`는 항상 관리자 콘솔 접근, 학습자·교수자 공간 접근,
approved 상태를 부여하는 신뢰 루트다(기본 `yunchan@twentyoz.kr`, `hoonjungkoo@hs.ac.kr`). 코호트는
`AUTH_EMAIL_COHORT_MAP``AUTH_DOMAIN_COHORT_MAP` 설정, SAML fixture의 `cohort` claim을 합쳐
`cohort_ids`로 세션에 저장한다. 관리 사용자 `app_user.external_id`는 provider subject 기반