diff --git a/.github/scripts/daily-digest.sh b/.github/scripts/daily-digest.sh index 2121213e..759ce8bb 100755 --- a/.github/scripts/daily-digest.sh +++ b/.github/scripts/daily-digest.sh @@ -59,6 +59,75 @@ echo "new issues: $ISSUES_COUNT" echo "commits main/dev: $COMMITS_MAIN_COUNT/$COMMITS_DEV_COUNT" echo "::endgroup::" +# --- admin verify stats (assets DB + operation DB) --- +# 두 DB 모두 secret 설정되어 있을 때만 활성. 없으면 silent skip. +echo "::group::verify-stats" + +VERIFY_STATS_JSON="[]" +VERIFY_TOTALS_JSON='{"yesterday":0,"last_7d":0}' +NAG_ADMINS_TEXT="" + +if [ -n "${ASSETS_DATABASE_URL_RO:-}" ] && [ -n "${OPERATION_DATABASE_URL_RO:-}" ]; then + # 1) admins from operation DB + admins_json=$(PGCONNECT_TIMEOUT=10 psql "$OPERATION_DATABASE_URL_RO" -t -A -F$'\t' -c \ + "SELECT id::text, username, COALESCE(display_name, '') FROM public.users WHERE is_admin = true ORDER BY username" \ + 2>/dev/null \ + | jq -R -s 'split("\n") | map(select(length > 0) | split("\t") | {id:.[0], username:.[1], display_name:(.[2] // "" | if length == 0 then null else . end)})' \ + || echo "[]") + + admin_count=$(echo "$admins_json" | jq 'length') + echo "admins: $admin_count" + + if [ "$admin_count" -gt 0 ]; then + # 2) verified counts per admin from assets DB + # yesterday 윈도우 = [어제 00:00 KST, 오늘 00:00 KST). last_7d 윈도우 = 7일 rolling. + admin_ids_array=$(echo "$admins_json" | jq -r '[.[].id] | join("\",\"")') + counts_json=$(PGCONNECT_TIMEOUT=10 psql "$ASSETS_DATABASE_URL_RO" -t -A -F$'\t' -c \ + "SELECT verified_by::text, + COUNT(*) FILTER ( + WHERE verified_at >= (date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') - interval '1 day') AT TIME ZONE 'Asia/Seoul' + AND verified_at < date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'Asia/Seoul' + ) AS yesterday, + COUNT(*) FILTER (WHERE verified_at >= now() - interval '7 days') AS last_7d + FROM public.raw_posts + WHERE verified_by = ANY(ARRAY[\"$admin_ids_array\"]::uuid[]) + AND verified_at IS NOT NULL + AND verified_at >= now() - interval '7 days' + GROUP BY verified_by" \ + 2>/dev/null \ + | jq -R -s 'split("\n") | map(select(length > 0) | split("\t") | {admin_id:.[0], yesterday:(.[1] | tonumber), last_7d:(.[2] | tonumber)})' \ + || echo "[]") + + # 3) zip — admin + counts (fall-through to 0 if no row) + VERIFY_STATS_JSON=$(jq -n \ + --argjson admins "$admins_json" \ + --argjson counts "$counts_json" \ + '[$admins[] as $a + | ($counts[] | select(.admin_id == $a.id)) // {yesterday: 0, last_7d: 0} + | {admin_id: $a.id, username: $a.username, display_name: $a.display_name, + yesterday: .yesterday, last_7d: .last_7d, needs_nag: (.yesterday == 0)}] + | sort_by(-.yesterday)') + + # 4) totals + nag list + VERIFY_TOTALS_JSON=$(echo "$VERIFY_STATS_JSON" | jq '{ + yesterday: (map(.yesterday) | add // 0), + last_7d: (map(.last_7d) | add // 0) + }') + + NAG_ADMINS_TEXT=$(echo "$VERIFY_STATS_JSON" | jq -r ' + map(select(.needs_nag)) | + if length == 0 then "" else + map((.display_name // .username)) | join(", ") + end') + + echo "totals: $(echo "$VERIFY_TOTALS_JSON" | jq -c .)" + echo "nag: ${NAG_ADMINS_TEXT:-(none)}" + fi +else + echo "skip — ASSETS_DATABASE_URL_RO and/or OPERATION_DATABASE_URL_RO not set" +fi +echo "::endgroup::" + # --- Claude Haiku summary --- echo "::group::summarize" @@ -69,25 +138,32 @@ DATA_JSON=$(jq -n \ --argjson open_issues_assigned "$OPEN_ISSUES_ASSIGNED" \ --argjson commits_main "$COMMITS_MAIN" \ --argjson commits_dev "$COMMITS_DEV" \ + --argjson verify_stats "$VERIFY_STATS_JSON" \ + --argjson verify_totals "$VERIFY_TOTALS_JSON" \ '{ merged_prs: $merged_prs, open_prs: $open_prs, new_issues: $new_issues, open_issues_assigned: $open_issues_assigned, commits_main: $commits_main, - commits_dev: $commits_dev + commits_dev: $commits_dev, + admin_verify_stats: $verify_stats, + admin_verify_totals: $verify_totals }') PROMPT_TEXT='당신은 decoded 모노레포의 일일 리포트를 한국어로 작성합니다. -아래 JSON은 지난 24시간의 GitHub 활동입니다. +아래 JSON은 지난 24시간의 GitHub 활동 + admin verify 통계입니다. 브랜치 맥락: decoded는 `feature/* → dev → main` 플로우. 대부분 작업은 dev에 병합되고, main은 릴리즈/CI 전용. +admin_verify_stats: admin 별 어제 verify (raw_post 검수) 카운트. `needs_nag=true` 인 admin 은 어제 0건이므로 부드럽게 독촉 메시지 추가. + 요청: - 주요 변화 3~5개를 **base 브랜치별로 그룹핑**해서 제시 (main → dev 순서) - 브랜치에 해당 항목이 없으면 해당 그룹 생략 - review 대기중이거나 오래된 open PR 있으면 "주의" 섹션 (주의는 그룹핑 없이 평평하게) -- 전체 400자 이내, plain text (마크다운 금지) +- admin_verify_stats 가 있고 needs_nag 인 admin 이 있으면 "독촉" 섹션 추가 (이름 명시, 가볍게 — 죄책감 X, 동기부여 톤) +- 전체 500자 이내, plain text (마크다운 금지) - 형식 (정확히 이 들여쓰기/기호 사용): ✨ 하이라이트 @@ -98,7 +174,10 @@ PROMPT_TEXT='당신은 decoded 모노레포의 일일 리포트를 한국어로 • 내용 요약 (#PR번호) ⚠️ 주의 -• 이슈 설명 (#번호, →base) — (해당 없으면 이 섹션 전체 생략)' +• 이슈 설명 (#번호, →base) — (해당 없으면 이 섹션 전체 생략) + +📣 독촉 — (needs_nag=true 인 admin 이 있을 때만 등장, 없으면 섹션 전체 생략) +• [이름]님 어제 검수 한 건도 없네요. 오늘은 한 건만이라도 부탁드려요!' CLAUDE_REQ=$(jq -n \ --arg model "claude-haiku-4-5-20251001" \ @@ -176,6 +255,18 @@ if [ -n "$top_issues" ]; then BODY+="📌 new issues (${ISSUES_COUNT})"$'\n'"$top_issues"$'\n\n' fi +# admin verify table (deterministic — Claude 요약과 별개로 항상 노출) +verify_table=$(echo "$VERIFY_STATS_JSON" | jq -r ' + if length == 0 then "" else + map("• \(.display_name // .username): 어제 \(.yesterday) / 7d \(.last_7d)\(if .needs_nag then " ⚠️" else "" end)") + | join("\n") + end') +if [ -n "$verify_table" ]; then + vy=$(echo "$VERIFY_TOTALS_JSON" | jq -r '.yesterday') + v7=$(echo "$VERIFY_TOTALS_JSON" | jq -r '.last_7d') + BODY+="🔍 admin verify (어제 ${vy} / 7d ${v7})"$'\n'"$verify_table"$'\n\n' +fi + MSG=$(cat </dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y -qq postgresql-client + fi + psql --version + - name: Run daily digest env: GH_TOKEN: ${{ github.token }} @@ -53,4 +63,6 @@ jobs: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} WINDOW_HOURS: ${{ github.event.inputs.window_hours || '24' }} + ASSETS_DATABASE_URL_RO: ${{ secrets.ASSETS_DATABASE_URL_RO }} + OPERATION_DATABASE_URL_RO: ${{ secrets.OPERATION_DATABASE_URL_RO }} run: bash .github/scripts/daily-digest.sh diff --git a/packages/api-server/src/domains/admin/handlers.rs b/packages/api-server/src/domains/admin/handlers.rs index e1eecb84..216e5e22 100644 --- a/packages/api-server/src/domains/admin/handlers.rs +++ b/packages/api-server/src/domains/admin/handlers.rs @@ -10,7 +10,7 @@ use super::{ badges, categories, curations, dashboard, editorial_article_chat, editorial_articles, editorial_candidates, editorial_discovery_settings, editorial_pipeline_settings, editorial_recommendations, gemini_cost, magazine_sessions, monitoring, posts, solutions, spots, - synonyms, + synonyms, verify_stats, }; use crate::domains::reports; @@ -58,6 +58,10 @@ pub fn router(state: AppState, app_config: AppConfig) -> Router { "/gemini-cost", gemini_cost::router(state.clone(), app_config.clone()), ) + .nest( + "/verify-stats", + verify_stats::router(state.clone(), app_config.clone()), + ) .nest("/badges", badges::router(app_config.clone())) .nest("/reports", reports::admin_router(app_config.clone())) .nest( diff --git a/packages/api-server/src/domains/admin/mod.rs b/packages/api-server/src/domains/admin/mod.rs index 4b124107..f3db17f4 100644 --- a/packages/api-server/src/domains/admin/mod.rs +++ b/packages/api-server/src/domains/admin/mod.rs @@ -20,6 +20,7 @@ pub mod posts; pub mod solutions; pub mod spots; pub mod synonyms; +pub mod verify_stats; pub use handlers::router; diff --git a/packages/api-server/src/domains/admin/verify_stats.rs b/packages/api-server/src/domains/admin/verify_stats.rs new file mode 100644 index 00000000..757a06cd --- /dev/null +++ b/packages/api-server/src/domains/admin/verify_stats.rs @@ -0,0 +1,250 @@ +//! Admin — verify observability. +//! +//! 어드민 (`users.is_admin=true`) 이 `assets.raw_posts.verified_by` 로 verify +//! 한 횟수를 today / yesterday / 7d / 30d 윈도우 별로 집계. +//! +//! cross-DB: 두 Supabase 프로젝트 분리 (#333) — assets 에서 카운트 + operation +//! 에서 admin 메타. 두 결과를 application 레벨에서 zip. +//! +//! 사용처: +//! - 어드민 UI `/admin/verify-stats` (chart + table) +//! - 어드민 home 의 작은 카드 (오늘 합계 + nag count) +//! - daily-digest 크론 메시지 (어제 admin 별 count + nag — `needs_nag=true`) + +use std::collections::HashMap; + +use axum::{ + extract::{Query, State}, + routing::get, + Json, Router, +}; +use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection, Statement}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + config::{AppConfig, AppState}, + error::{AppError, AppResult}, + middleware::auth::User, +}; + +#[derive(Debug, Deserialize)] +pub struct DaysQuery { + #[serde(default)] + pub days: Option, +} + +#[derive(Debug, Serialize)] +pub struct AdminVerifyRow { + pub admin_id: String, + pub username: String, + pub display_name: Option, + pub email: String, + pub verify_count_today: i64, + pub verify_count_yesterday: i64, + pub verify_count_7d: i64, + pub verify_count_30d: i64, + pub last_verify_at: Option, + pub needs_nag: bool, // 어제 0건이면 true +} + +#[derive(Debug, Serialize)] +pub struct VerifyStatsTotals { + pub today: i64, + pub yesterday: i64, + pub last_7d: i64, + pub last_30d: i64, +} + +#[derive(Debug, Serialize)] +pub struct VerifyStatsResponse { + pub as_of: String, + pub admins: Vec, + pub totals: VerifyStatsTotals, + pub nag_admin_ids: Vec, +} + +pub async fn get_verify_stats( + State(state): State, + _user: axum::Extension, + Query(_q): Query, +) -> AppResult> { + let prod_db = state.db.as_ref(); + let assets_db = state.assets_db.as_ref(); + + // 1) operation DB — admin 목록 + let admins = fetch_admin_users(prod_db).await?; + if admins.is_empty() { + return Ok(Json(VerifyStatsResponse { + as_of: chrono::Utc::now().to_rfc3339(), + admins: vec![], + totals: VerifyStatsTotals { + today: 0, + yesterday: 0, + last_7d: 0, + last_30d: 0, + }, + nag_admin_ids: vec![], + })); + } + + // 2) assets DB — verified_by 별 윈도우 카운트 + let admin_ids: Vec = admins.iter().map(|a| a.id).collect(); + let counts = fetch_verify_counts(assets_db, &admin_ids).await?; + + // 3) zip + let mut admin_rows: Vec = admins + .into_iter() + .map(|a| { + let c = counts.get(&a.id).copied().unwrap_or_default(); + AdminVerifyRow { + admin_id: a.id.to_string(), + username: a.username, + display_name: a.display_name, + email: a.email, + verify_count_today: c.today, + verify_count_yesterday: c.yesterday, + verify_count_7d: c.last_7d, + verify_count_30d: c.last_30d, + last_verify_at: c.last_at.map(|t| t.to_rfc3339()), + needs_nag: c.yesterday == 0, + } + }) + .collect(); + // 정렬: 오늘 많이 한 순 → 어제 → 7d + admin_rows.sort_by(|a, b| { + b.verify_count_today + .cmp(&a.verify_count_today) + .then(b.verify_count_yesterday.cmp(&a.verify_count_yesterday)) + .then(b.verify_count_7d.cmp(&a.verify_count_7d)) + }); + + let totals = VerifyStatsTotals { + today: admin_rows.iter().map(|r| r.verify_count_today).sum(), + yesterday: admin_rows.iter().map(|r| r.verify_count_yesterday).sum(), + last_7d: admin_rows.iter().map(|r| r.verify_count_7d).sum(), + last_30d: admin_rows.iter().map(|r| r.verify_count_30d).sum(), + }; + let nag_admin_ids: Vec = admin_rows + .iter() + .filter(|r| r.needs_nag) + .map(|r| r.admin_id.clone()) + .collect(); + + Ok(Json(VerifyStatsResponse { + as_of: chrono::Utc::now().to_rfc3339(), + admins: admin_rows, + totals, + nag_admin_ids, + })) +} + +struct AdminUser { + id: Uuid, + username: String, + display_name: Option, + email: String, +} + +async fn fetch_admin_users(db: &DatabaseConnection) -> AppResult> { + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT id, username, display_name, email + FROM public.users + WHERE is_admin = true + ORDER BY username", + Vec::::new(), + ); + let rows = db.query_all(stmt).await.map_err(AppError::DatabaseError)?; + let mut out = Vec::with_capacity(rows.len()); + for r in rows { + out.push(AdminUser { + id: r.try_get("", "id").map_err(AppError::DatabaseError)?, + username: r.try_get("", "username").map_err(AppError::DatabaseError)?, + display_name: r + .try_get("", "display_name") + .map_err(AppError::DatabaseError)?, + email: r.try_get("", "email").map_err(AppError::DatabaseError)?, + }); + } + Ok(out) +} + +#[derive(Default, Clone, Copy)] +struct VerifyCounts { + today: i64, + yesterday: i64, + last_7d: i64, + last_30d: i64, + last_at: Option>, +} + +async fn fetch_verify_counts( + db: &DatabaseConnection, + admin_ids: &[Uuid], +) -> AppResult> { + if admin_ids.is_empty() { + return Ok(HashMap::new()); + } + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT verified_by AS admin_id, + COUNT(*) FILTER ( + WHERE verified_at >= date_trunc('day', now()) + ) AS today, + COUNT(*) FILTER ( + WHERE verified_at >= date_trunc('day', now()) - interval '1 day' + AND verified_at < date_trunc('day', now()) + ) AS yesterday, + COUNT(*) FILTER ( + WHERE verified_at >= now() - interval '7 days' + ) AS last_7d, + COUNT(*) FILTER ( + WHERE verified_at >= now() - interval '30 days' + ) AS last_30d, + MAX(verified_at) AS last_at + FROM public.raw_posts + WHERE verified_by = ANY($1::uuid[]) + AND verified_at IS NOT NULL + AND verified_at >= now() - interval '30 days' + GROUP BY verified_by", + vec![admin_ids.to_vec().into()], + ); + let rows = db.query_all(stmt).await.map_err(AppError::DatabaseError)?; + let mut out: HashMap = HashMap::new(); + for r in rows { + let id: Uuid = r.try_get("", "admin_id").map_err(AppError::DatabaseError)?; + let today: i64 = r.try_get("", "today").map_err(AppError::DatabaseError)?; + let yesterday: i64 = r + .try_get("", "yesterday") + .map_err(AppError::DatabaseError)?; + let last_7d: i64 = r.try_get("", "last_7d").map_err(AppError::DatabaseError)?; + let last_30d: i64 = r.try_get("", "last_30d").map_err(AppError::DatabaseError)?; + let last_at: Option> = + r.try_get("", "last_at").map_err(AppError::DatabaseError)?; + out.insert( + id, + VerifyCounts { + today, + yesterday, + last_7d, + last_30d, + last_at, + }, + ); + } + Ok(out) +} + +pub fn router(state: AppState, app_config: AppConfig) -> Router { + Router::new() + .route("/", get(get_verify_stats)) + .layer(axum::middleware::from_fn_with_state( + state, + crate::middleware::admin_db_middleware, + )) + .layer(axum::middleware::from_fn_with_state( + app_config, + crate::middleware::auth_middleware, + )) +} diff --git a/packages/web/app/admin/page.tsx b/packages/web/app/admin/page.tsx index 8f340341..bd817d78 100644 --- a/packages/web/app/admin/page.tsx +++ b/packages/web/app/admin/page.tsx @@ -20,6 +20,7 @@ import { } from "@/lib/components/admin/dashboard/TodaySummary"; import { PipelineHealthCard } from "@/lib/components/admin/dashboard/PipelineHealthCard"; import { GeminiCostMini } from "@/lib/components/admin/dashboard/GeminiCostMini"; +import { VerifyStatsMini } from "@/lib/components/admin/dashboard/VerifyStatsMini"; /** * Admin Dashboard Page @@ -60,6 +61,9 @@ export default function AdminDashboardPage() { {/* Gemini API cost mini (#cost-tracking) */} + {/* Admin verify stats mini (#admin-verify-observability) */} + + {/* Traffic Chart */} {chartQuery.isLoading ? ( diff --git a/packages/web/app/admin/verify-stats/page.tsx b/packages/web/app/admin/verify-stats/page.tsx new file mode 100644 index 00000000..00d49865 --- /dev/null +++ b/packages/web/app/admin/verify-stats/page.tsx @@ -0,0 +1,184 @@ +"use client"; + +/** + * /admin/verify-stats — Admin verify observability (#admin-verify-observability). + * + * users.is_admin=true 사용자별 verify 횟수 (today / yesterday / 7d / 30d). + * 어제 0건인 admin 은 ⚠️ nag 표시 — daily-digest 텔레그램 메시지에서도 + * 같은 정보가 톤만 다르게 노출됨. + */ + +import { useVerifyStats } from "@/lib/hooks/admin/useVerifyStats"; + +function fmtAdmin(name: string | null, username: string): string { + return name && name.trim().length > 0 ? `${name} (${username})` : username; +} + +function fmtRelative(iso: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + const now = Date.now(); + const diffSec = Math.floor((now - d.getTime()) / 1000); + if (diffSec < 60) return "방금 전"; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}분 전`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}시간 전`; + return `${Math.floor(diffSec / 86400)}일 전`; +} + +export default function VerifyStatsPage() { + const { data, isLoading, error } = useVerifyStats(); + + return ( +
+
+

+ Verify Stats +

+

+ Admin (`users.is_admin=true`) 별 raw_post verify 카운트. 어제 0건은 ⚠️ + 표시. +

+
+ + {/* Totals KPI */} +
+ + + + +
+ + {/* Nag banner */} + {(data?.nag_admin_ids.length ?? 0) > 0 && ( +
+
+ ⚠️ {data!.nag_admin_ids.length}명의 admin이 어제 verify 0건 +
+
+ daily-digest 텔레그램에도 노출됨. +
+
+ )} + + {/* Per-admin table */} +
+ + + + + + + + + + + + + {isLoading ? ( + + + + ) : error ? ( + + + + ) : !data || data.admins.length === 0 ? ( + + + + ) : ( + data.admins.map((row) => ( + + + + + + + + + )) + )} + +
AdminTodayYesterday7d30dLast verify
+ Loading… +
+ {(error as Error).message} +
+ No admin accounts found. +
+ + {row.needs_nag && "⚠️ "} + {fmtAdmin(row.display_name, row.username)} + +
{row.email}
+
+ {row.verify_count_today} + + {row.verify_count_yesterday} + + {row.verify_count_7d} + + {row.verify_count_30d} + + {fmtRelative(row.last_verify_at)} +
+
+ + {data?.as_of && ( +
+ as of {new Date(data.as_of).toLocaleString()} +
+ )} +
+ ); +} + +function KpiCard({ + label, + value, + loading, +}: { + label: string; + value: number | undefined; + loading: boolean; +}) { + return ( +
+
+ {label} +
+
+ {loading ? "—" : (value ?? 0)} +
+
+ ); +} diff --git a/packages/web/app/api/admin/verify-stats/route.ts b/packages/web/app/api/admin/verify-stats/route.ts new file mode 100644 index 00000000..dc3027bc --- /dev/null +++ b/packages/web/app/api/admin/verify-stats/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { checkIsAdmin } from "@/lib/supabase/admin"; +import { API_BASE_URL } from "@/lib/server-env"; + +/** + * GET /api/admin/verify-stats → api-server `/api/v1/admin/verify-stats`. + * Admin auth + bearer forward. + */ +export async function GET(request: NextRequest) { + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const isAdmin = await checkIsAdmin(supabase, user.id); + if (!isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session?.access_token) { + return NextResponse.json({ error: "No session" }, { status: 401 }); + } + + const qs = request.nextUrl.searchParams.toString(); + const url = `${API_BASE_URL}/api/v1/admin/verify-stats${qs ? `?${qs}` : ""}`; + + try { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${session.access_token}` }, + }); + const text = await response.text(); + if (!text) { + return new NextResponse(null, { status: response.status }); + } + let data: unknown; + try { + data = JSON.parse(text); + } catch { + data = { message: text }; + } + return NextResponse.json(data, { status: response.status }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("verify-stats proxy error:", error); + } + return NextResponse.json( + { message: error instanceof Error ? error.message : "Proxy error" }, + { status: 502 } + ); + } +} diff --git a/packages/web/lib/components/admin/AdminSidebar.tsx b/packages/web/lib/components/admin/AdminSidebar.tsx index de7b8c4b..cb4de1fe 100644 --- a/packages/web/lib/components/admin/AdminSidebar.tsx +++ b/packages/web/lib/components/admin/AdminSidebar.tsx @@ -16,6 +16,7 @@ import { UsersRound, Link2, DollarSign, + ShieldCheck, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/lib/stores/authStore"; @@ -67,6 +68,11 @@ const SIDEBAR_ENTRIES: SidebarEntry[] = [ label: "Observability", items: [ { href: "/admin/gemini-cost", label: "Gemini Cost", icon: DollarSign }, + { + href: "/admin/verify-stats", + label: "Verify Stats", + icon: ShieldCheck, + }, ], }, ]; diff --git a/packages/web/lib/components/admin/dashboard/VerifyStatsMini.tsx b/packages/web/lib/components/admin/dashboard/VerifyStatsMini.tsx new file mode 100644 index 00000000..d2fda2f2 --- /dev/null +++ b/packages/web/lib/components/admin/dashboard/VerifyStatsMini.tsx @@ -0,0 +1,75 @@ +"use client"; + +/** + * /admin home 의 작은 verify-stats 카드 (#admin-verify-observability). + * 어제 admin verify total + nag count 노출. /admin/verify-stats 로 drill-down. + */ + +import Link from "next/link"; +import { useVerifyStats } from "@/lib/hooks/admin/useVerifyStats"; + +export function VerifyStatsMini() { + const { data, isLoading } = useVerifyStats(); + const nagCount = data?.nag_admin_ids.length ?? 0; + const hasNag = nagCount > 0; + + return ( + +
+

+ Admin Verify Stats +

+ view → +
+ {isLoading ? ( +
+ ) : ( +
+
+
+ Today +
+
+ {data?.totals.today ?? 0} +
+
+
+
+ Yesterday +
+
+ {data?.totals.yesterday ?? 0} +
+
+
+
+ 7d +
+
+ {data?.totals.last_7d ?? 0} +
+
+
+
+ Nag +
+
+ {hasNag ? `⚠️ ${nagCount}` : "0"} +
+
+
+ )} + + ); +} diff --git a/packages/web/lib/hooks/admin/useVerifyStats.ts b/packages/web/lib/hooks/admin/useVerifyStats.ts new file mode 100644 index 00000000..b3cb7bdd --- /dev/null +++ b/packages/web/lib/hooks/admin/useVerifyStats.ts @@ -0,0 +1,54 @@ +"use client"; + +/** + * Admin verify-stats hook (#admin-verify-observability). + * + * GET /api/admin/verify-stats + * + * Returns per-admin verify counts for today / yesterday / 7d / 30d + nag list + * (어제 0건 admin id). + */ + +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; + +export interface AdminVerifyRow { + admin_id: string; + username: string; + display_name: string | null; + email: string; + verify_count_today: number; + verify_count_yesterday: number; + verify_count_7d: number; + verify_count_30d: number; + last_verify_at: string | null; + needs_nag: boolean; +} + +export interface VerifyStatsTotals { + today: number; + yesterday: number; + last_7d: number; + last_30d: number; +} + +export interface VerifyStatsResponse { + as_of: string; + admins: AdminVerifyRow[]; + totals: VerifyStatsTotals; + nag_admin_ids: string[]; +} + +export function useVerifyStats(): UseQueryResult { + return useQuery({ + queryKey: ["admin", "verify-stats"], + queryFn: async ({ signal }) => { + const res = await fetch("/api/admin/verify-stats", { signal }); + if (!res.ok) { + throw new Error(`verify-stats failed: ${res.status}`); + } + return (await res.json()) as VerifyStatsResponse; + }, + refetchInterval: 60_000, + staleTime: 30_000, + }); +}