diff --git a/apps/api/app/services/notifications.py b/apps/api/app/services/notifications.py
index 33a32be..465bcc1 100644
--- a/apps/api/app/services/notifications.py
+++ b/apps/api/app/services/notifications.py
@@ -184,12 +184,12 @@ 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."""
+ """Queue one explicit super-admin test email through the normal delivery path."""
payload = {
"requested_by": actor_email,
"notifications_url": _frontend_url("/admin"),
}
- recipients = await _admin_approval_recipients()
+ recipients = await _super_admin_recipients()
await _enqueue_event(
kind="admin_test_email",
idempotency_key=f"admin_test_email:{uuid4()}",
@@ -349,6 +349,33 @@ async def _admin_approval_recipients() -> list[NotificationRecipient]:
return [_recipient_from_row(row) for row in rows]
+async def _super_admin_recipients() -> list[NotificationRecipient]:
+ super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
+ if not super_admin_emails:
+ return []
+ 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 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]]:
diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html
index f278f8e..b337b15 100644
--- a/docs/dev_dashboard.html
+++ b/docs/dev_dashboard.html
@@ -532,7 +532,7 @@
96차 적용(2026-06-29): M2 digest worker를 운영자가 명시 실행할 수 있도록 scripts/run-session-digest-worker.py dry-run/apply runner를 추가했다. 기본은 metadata-only dry-run이고, --apply를 줘야 accepted 후보를 DB에 반영한다. runner는 load → DB release → engine generate → short apply 순서로 동작해 LLM 호출 동안 DB transaction을 잡지 않는다. digest 본문은 민감할 수 있어 --show-digest를 명시할 때만 출력한다. 이는 실 provider 장시간 운영 완료가 아니다. 검증: py -3.11 -X utf8 -B -m py_compile scripts\run-session-digest-worker.py apps\api\app\services\session_digest_worker.py apps\api\app\test_session_digest_worker.py, py -3.11 -X utf8 scripts\run-session-digest-worker.py --help, M2 focused 25 passed.
97차 적용(2026-06-29): SESSION_DIGEST_WORKER_ENABLED=false 기본값을 두고, opt-in일 때만 세션 종료 성공 뒤 digest worker를 background task로 예약한다. scheduler는 persisted fallback row를 load한 뒤 DB connection을 놓고 engine을 호출하며, accepted plan만 짧은 apply transaction으로 반영한다. 이미 compressed_by가 있는 세션은 loader에서 제외하고, apply도 compressed_by IS NULL CAS로 보호하며, fallback summary 재작성 시 compression metadata를 초기화한다. 이는 bounded/default-off scheduler 골격이며, 실 provider 장시간 운영·임상 골든셋 품질평가·재압축 정책 완료가 아니다. 검증: py_compile, runner --help, M2 focused 30 passed.
99차 적용(2026-06-29): 대시보드 SSOT drift gate를 보강했다. scripts/check-dev-dashboard-ssot.py는 현재 카드 상태(done=25, planned=0, doing=0), CHECK 잔여 0, owner board counts(block=2, decide=7, ext=2), 외부 GATE 상세 6행, 결정 탭 7행, 자유연습 F-10 결정 문구, M2 30/87 검증 수치, default-off scheduler/CAS 문구, 실 provider·임상 골든셋·재압축 GATE 보존, stale 82/25/26 passed 및 낡은 결정 8건 문구 제거를 함께 확인한다. 검증: py_compile, py -3.11 -X utf8 scripts\check-dev-dashboard-ssot.py --json PASS. 아래 적용 노트 일부는 historical snapshot이라 당시 통과 수치를 그대로 보존한다.
- 100차 적용(2026-06-29): 관리자·교수자 메일링 시스템 1차를 추가했다. 신규 외부 로그인 pending 계정은 account_pending_approval:{user_id}, 회기 종료 후 평가/error record 저장 완료 세션은 session_review_ready:{session_id}, 명시 테스트 메일은 admin_test_email:{uuid} idempotency key로 app.notification_event/app.notification_delivery에 큐잉된다. 메일 HTML은 Vignette 톤앤매너(종이 배경, 세이지 CTA, 8px 카드)를 inline style로 유지하고, 본문에는 축어록·평가 전문을 넣지 않고 /admin/users, /teach/session/:sessionId/review, /admin 딥링크만 제공한다. NOTIFICATION_EMAIL_PROVIDER=smtp와 SMTP_*가 설정된 경우 실제 발송하며, GET /admin/notifications, POST /admin/notifications/process, POST /admin/notifications/test, scripts/run-notification-worker.py로 delivery 상태와 재시도를 운영한다.
+ 100차 적용(2026-06-29): 관리자·교수자 메일링 시스템 1차를 추가했다. 신규 외부 로그인 pending 계정은 account_pending_approval:{user_id}, 회기 종료 후 평가/error record 저장 완료 세션은 session_review_ready:{session_id}, 명시 테스트 메일은 admin_test_email:{uuid} idempotency key로 app.notification_event/app.notification_delivery에 큐잉된다. 테스트 메일은 대량 오발송을 피하기 위해 AUTH_SUPER_ADMIN_EMAILS 대상에게만 큐잉한다. 메일 HTML은 Vignette 톤앤매너(종이 배경, 세이지 CTA, 8px 카드)를 inline style로 유지하고, 본문에는 축어록·평가 전문을 넣지 않고 /admin/users, /teach/session/:sessionId/review, /admin 딥링크만 제공한다. NOTIFICATION_EMAIL_PROVIDER=smtp와 SMTP_*가 설정된 경우 실제 발송하며, GET /admin/notifications, POST /admin/notifications/process, POST /admin/notifications/test, scripts/run-notification-worker.py로 delivery 상태와 재시도를 운영한다.
20차 적용(2026-06-28): 신규 Google/SAML 사용자는 account_status=pending으로 시작하고 승인 전에는 /pending 안내 화면만 본다. yunchan@twentyoz.kr는 슈퍼 관리자 allowlist로 admin+approved를 받으며, /admin/users는 가입 승인 탭에서 pending 계정을 승인 또는 보류 처리한다.
21차 적용(2026-06-28): 관리자 페이지 진입권을 기본 역할과 분리해 app_user.admin_access로 저장한다. AUTH_SUPER_ADMIN_EMAILS 기본값은 yunchan@twentyoz.kr, hoonjungkoo@hs.ac.kr이며, 슈퍼 관리자는 학습자·교수자·관리자 공간 전환과 관리자 권한 부여/회수를 할 수 있다. 학생·교수 계정도 admin_access=true면 우측 상단 관리자 진입이 노출된다. 구성 슈퍼 관리자의 권한 회수와 계정 비활성화는 차단한다.
25차 적용(2026-06-28): /admin/users에서 허용 도메인 밖 이메일도 정확한 계정 단위로 강제 등록할 수 있다. Google/SAML/dev-login은 미리 등록된 이메일만 도메인 게이트 예외로 통과시키고, provider 로그인 시 기존 관리 row의 역할·코호트·승인 상태를 이어받는다. 미등록 외부 도메인 로그인은 계속 차단한다.
diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md
index b7d7706..451e3f1 100644
--- a/docs/guides/architecture.md
+++ b/docs/guides/architecture.md
@@ -397,8 +397,8 @@ session lifecycle을 유지하며, future Node read API는 이 read-model contra
축어록, 평가 전문, 민감한 심리 상태를 넣지 않고, 로그인 후 앱 화면에서만 확인하게 한다.
- 운영 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}` 테스트 이벤트를 만들고
- 같은 발송 큐로 즉시 처리한다.
+ `POST /admin/notifications/test`는 `AUTH_SUPER_ADMIN_EMAILS` 대상에게 `admin_test_email:{uuid}` 테스트
+ 이벤트를 만들고 같은 발송 큐로 즉시 처리한다.
### 2.9.3 공개 공유·검색 메타 — `app/routes/share.py`
diff --git a/docs/guides/local-development.md b/docs/guides/local-development.md
index 92c5e31..932646b 100644
--- a/docs/guides/local-development.md
+++ b/docs/guides/local-development.md
@@ -294,8 +294,8 @@ python scripts\run-notification-worker.py --limit 25
```
관리자 API에서도 `GET /admin/notifications`로 최근 delivery를 보고,
-`POST /admin/notifications/process`로 한 번 처리할 수 있다. `POST /admin/notifications/test`는 관리자
-수신자에게 테스트 메일 이벤트를 만들고 같은 큐로 즉시 처리한다. 메일 본문에는 축어록이나 평가 전문을 넣지 않고,
+`POST /admin/notifications/process`로 한 번 처리할 수 있다. `POST /admin/notifications/test`는
+`AUTH_SUPER_ADMIN_EMAILS` 대상에게 테스트 메일 이벤트를 만들고 같은 큐로 즉시 처리한다. 메일 본문에는 축어록이나 평가 전문을 넣지 않고,
`/admin/users`, `/teach/session/:sessionId/review`, `/admin` 링크만 제공한다.
### 3.2 PowerShell(권장) — Invoke-RestMethod + 세션 쿠키
diff --git a/docs/ops/backlog-2026-06-26.md b/docs/ops/backlog-2026-06-26.md
index 6e1004d..cb00ee3 100644
--- a/docs/ops/backlog-2026-06-26.md
+++ b/docs/ops/backlog-2026-06-26.md
@@ -22,7 +22,7 @@
- [x] **SEO/GEO 기본 신호 + 회기 리뷰 URL 공유 카드** — (2026-06-27 처리) `index.html`에 canonical/description/Open Graph/Twitter Card/JSON-LD를 추가하고, `robots.txt`/`sitemap.xml`/`llms.txt`를 게시했다. 종료된 학습자 회기는 `POST /sessions/{id}/share`가 공개 토큰을 생성하고 `GET /share/session/{token}`이 서버 HTML로 요약·썸네일·JSON-LD를 내려 URL unfurl을 지원한다. `app.session_share_link`에는 토큰 해시와 sanitized preview payload만 저장하며 원문 축어록·학습자 식별자는 포함하지 않는다. 공유 페이지는 교수자 전달용이라 `noindex`를 유지한다. **검증: `pytest app/test_session_share.py app/test_session_turn_persistence.py -q` 21 passed, `python -m pytest app/ -q` 178 passed, `python -m pytest engine_gateway/ -q` 11 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `PLAYWRIGHT_PORT=5174 npm run e2e` 113 passed.** 배포 후 실제 카카오톡/Slack/메일 unfurl 1회 확인 권장.
- [x] **학습자·교수자·운영·페르소나 스튜디오 레이아웃 정렬** — (2026-06-28 처리) 작업형 화면에만 `AppShell wide` 폭 정책을 적용하고, 학습자 홈 회기 목록은 큐형 행으로 정리했다. 교수 콘솔은 검토 대기·위기 알림·페르소나 검수·페르소나 저작실 순서가 DOM과 화면 모두 일치하며, 운영 콘솔 사용자/티켓은 조밀한 운영 행으로 낮췄다. 페르소나 스튜디오는 SSOT 첨부/RAG 생성/항목 편집/검증 큐를 한 작업면에서 유지한다. 리포트와 대표 캡처: `docs/ops/layout-research-2026-06-28/dashboard-layout-alignment-report.md`, `docs/ops/layout-research-2026-06-28/final-gate-*.png`. **검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --workers=1` 7 passed, `npx playwright test e2e/session-layout.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1` 8 passed, `npx playwright test e2e/learner.spec.ts e2e/teacher.spec.ts e2e/admin.spec.ts --project=chromium-desktop --workers=1` 17 passed.**
- [x] **관리자 권한 위임 1차** — (2026-06-28 처리, 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] **관리자·교수자 메일링 시스템 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` 상태와 재시도 정보를 보관한다. 테스트 메일은 대량 오발송을 피하기 위해 `AUTH_SUPER_ADMIN_EMAILS` 대상에게만 큐잉한다. SMTP 설정은 `NOTIFICATION_EMAIL_PROVIDER=smtp`, `SMTP_*` env로 주입하며, 관리자 API `GET /admin/notifications`, `POST /admin/notifications/process`, `POST /admin/notifications/test`와 `scripts/run-notification-worker.py`로 큐를 확인·처리한다. HTML 메일은 Vignette 종이 배경/세이지 CTA 톤을 inline style로 유지하고, 본문에는 축어록·평가 전문을 넣지 않는다. **검증: `python -m compileall apps\api\app\services\notifications.py apps\api\app\routes\admin.py apps\api\app\routes\sessions.py apps\api\app\auth_sessions.py apps\api\app\routes\users.py`, `python -B -m pytest -p no:cacheprovider app/test_notifications.py app/test_auth_providers.py app/test_admin_ops.py app/test_runtime_policy.py -q` 69 passed, `npm run generate:api-types`, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `python scripts\check-deploy-preflight.py --env-file infra\.env.example --allow-placeholder-secrets --skip-db`, `python scripts\run-notification-worker.py --help`, `python -X utf8 scripts\check-dev-dashboard-ssot.py --json` PASS.**
- [x] **회기 아카이브 저장/복원 API** — (2026-06-28 처리) `/learn/history`의 `보관됨`을 실제 학습자별 보기 상태로 연결했다. 종료 회기는 `POST /sessions/{id}/archive`/`restore`로 보관·복원하고, `app.session_archive_state`는 삭제가 아니라 `archived_at`/`updated_at`만 저장한다. `LearnerSessionSummary.archived`, `SessionArchiveResponse`, `LearnerDashboardOverview.archived_sessions`를 OpenAPI에 고정했고, 보관된 회기는 리뷰 대기 행동 큐에서 빠진다. 회기·턴·리뷰·공유 링크·연구/감사 증거는 삭제하지 않는다. **검증: `python -B -m pytest app/ -q` 178 passed, `python -B -m pytest engine_gateway/ -q` 11 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `npx playwright test e2e/learner.spec.ts --project=chromium-desktop --workers=1` 6 passed.**
- [x] **OpenAI TTS voice preset DB map 연결** — (2026-06-28 처리) `app.persona_voice_map`의 OpenAI row를 `/voice/ws` TTS voice 선택에 연결했다. 명시 query preset은 DB map보다 우선하고, 세션 바인딩은 `app.sessions.persona_id/persona_version`으로 voice map을 찾으며, dev persona 생성은 catalog persona id/version map을 사용한다. seed materializer는 기본 OpenAI voice map을 `ON CONFLICT DO NOTHING`으로 생성하고, dev 런타임 스키마 보강은 기존 DB의 `persona_voice_map` 누락도 복구한다. OpenAI가 아닌 provider row는 기존 persona-code fallback으로 안전하게 흡수한다. **검증: `pytest app/test_runtime_policy.py app/test_persona_review.py app/test_voice_service.py app/test_voice_ws.py -q` 75 passed, `pytest app/ -q` 178 passed, `pytest engine_gateway/ -q` 11 passed.**
- [x] **빈상태 컬럼 높이 여백** — (2026-06-28 처리) session-review 0건 상태는 1280px 이상에서 3컬럼 masonry를 쓰지 않고 빈 리뷰 전용 2컬럼 순차 레이아웃으로 전환한다. 가짜 기록·장식 콘텐츠를 넣지 않고, `EMPTY_REVIEW_SESSION_ID` fixture와 `session-review-empty` visual gate로 390/720/861/900/1024/1280/1440 전 폭을 검증한다. session-prestart와 learner-home은 기존 gate 대상에 남겨 함께 무회귀 확인했다. **검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --workers=1` 9 passed, `npx playwright test e2e/session-layout.spec.ts e2e/session-review.spec.ts --project=chromium-desktop --workers=1` 6 passed.**