From 4633c9bbf474dc5919ac2011589e3a1b43dbf5b8 Mon Sep 17 00:00:00 2001 From: CIOI Date: Mon, 25 May 2026 23:51:58 +0900 Subject: [PATCH 1/6] fix(admin): serve instagram enrichment list via api-server, not web service role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #578 — move operation DB reads to GET /api/v1/admin/entity-enrichment/instagram-accounts so Vercel web never needs DATABASE_SERVICE_ROLE_KEY. Web BFF proxies with admin JWT only. Co-authored-by: Cursor --- .../src/domains/admin/entity_enrichment.rs | 389 ++++++++++++++++++ .../api-server/src/domains/admin/handlers.rs | 6 +- packages/api-server/src/domains/admin/mod.rs | 1 + .../data-pipeline/instagram-accounts/route.ts | 180 ++++---- 4 files changed, 471 insertions(+), 105 deletions(-) create mode 100644 packages/api-server/src/domains/admin/entity_enrichment.rs diff --git a/packages/api-server/src/domains/admin/entity_enrichment.rs b/packages/api-server/src/domains/admin/entity_enrichment.rs new file mode 100644 index 00000000..c3e0da8e --- /dev/null +++ b/packages/api-server/src/domains/admin/entity_enrichment.rs @@ -0,0 +1,389 @@ +//! Admin — entity enrichment Instagram accounts queue. +//! +//! Operation DB (`public.instagram_accounts`) reads live on api-server only so +//! Vercel web never needs `DATABASE_SERVICE_ROLE_KEY`. + +use axum::{ + extract::{Query, State}, + routing::get, + Json, Router, +}; +use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection, Statement, Value}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use uuid::Uuid; + +use crate::{ + config::{AppConfig, AppState}, + error::{AppError, AppResult}, + middleware::auth::User, +}; + +const DEFAULT_LIMIT: i64 = 25; +const MAX_LIMIT: i64 = 100; + +#[derive(Debug, Deserialize)] +pub struct InstagramAccountsQuery { + #[serde(default)] + pub limit: Option, + #[serde(default)] + pub offset: Option, + #[serde(default)] + pub account_type: Option, + #[serde(default)] + pub entity_ig_role: Option, + #[serde(default)] + pub search: Option, +} + +#[derive(Debug, Serialize)] +pub struct InstagramAccountRow { + pub id: String, + pub username: String, + pub name_en: Option, + pub name_ko: Option, + pub display_name: Option, + pub bio: Option, + pub profile_image_url: Option, + pub account_type: String, + pub entity_ig_role: String, + pub entity_region_code: Option, + pub needs_review: Option, + pub artist_id: Option, + pub brand_id: Option, + pub group_id: Option, + pub metadata: Option, + pub updated_at: String, +} + +#[derive(Debug, Serialize)] +pub struct Pagination { + pub limit: i64, + pub offset: i64, + pub total: i64, +} + +#[derive(Debug, Serialize)] +pub struct InstagramAccountsSummary { + pub total: i64, + pub by_status: std::collections::HashMap, + pub by_account_type: std::collections::HashMap, + pub by_role: std::collections::HashMap, +} + +#[derive(Debug, Serialize)] +pub struct InstagramAccountsResponse { + pub accounts: Vec, + pub pagination: Pagination, + pub summary: InstagramAccountsSummary, +} + +/// GET /api/v1/admin/entity-enrichment/instagram-accounts +pub async fn list_instagram_accounts( + State(state): State, + _user: axum::Extension, + Query(query): Query, +) -> AppResult> { + let limit = query + .limit + .unwrap_or(DEFAULT_LIMIT) + .clamp(1, MAX_LIMIT); + let offset = query.offset.unwrap_or(0).max(0); + let account_type = non_empty(query.account_type); + let entity_ig_role = non_empty(query.entity_ig_role); + let search_pattern = query + .search + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(escape_ilike_pattern); + + let db = state.db.as_ref(); + let (total, accounts) = fetch_accounts( + db, + limit, + offset, + account_type.as_deref(), + entity_ig_role.as_deref(), + search_pattern.as_deref(), + ) + .await?; + let summary = fetch_summary(db).await?; + + Ok(Json(InstagramAccountsResponse { + accounts, + pagination: Pagination { + limit, + offset, + total, + }, + summary, + })) +} + +fn optional_text_value(value: Option<&str>) -> Value { + match value { + Some(v) => v.into(), + None => Value::String(None), + } +} + +fn non_empty(value: Option) -> Option { + value.and_then(|s| { + let t = s.trim().to_string(); + if t.is_empty() { + None + } else { + Some(t) + } + }) +} + +fn escape_ilike_pattern(search: &str) -> String { + let escaped = search + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + format!("%{escaped}%") +} + +async fn fetch_accounts( + db: &DatabaseConnection, + limit: i64, + offset: i64, + account_type: Option<&str>, + entity_ig_role: Option<&str>, + search_pattern: Option<&str>, +) -> AppResult<(i64, Vec)> { + let where_sql = "($1::text IS NULL OR account_type = $1) + AND ($2::text IS NULL OR entity_ig_role = $2) + AND ( + $3::text IS NULL + OR username ILIKE $3 ESCAPE '\\' + OR name_en ILIKE $3 ESCAPE '\\' + OR name_ko ILIKE $3 ESCAPE '\\' + OR display_name ILIKE $3 ESCAPE '\\' + )"; + + let filter_values = vec![ + optional_text_value(account_type), + optional_text_value(entity_ig_role), + optional_text_value(search_pattern), + ]; + + let count_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + &format!( + "SELECT COUNT(*)::bigint AS total + FROM public.instagram_accounts + WHERE {where_sql}" + ), + filter_values.clone(), + ); + let count_row = db + .query_one(count_stmt) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::internal("count query returned no row"))?; + let total: i64 = count_row + .try_get("", "total") + .map_err(AppError::DatabaseError)?; + + let mut list_values = filter_values; + list_values.push(limit.into()); + list_values.push(offset.into()); + + let list_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + &format!( + "SELECT id, username, name_en, name_ko, display_name, bio, profile_image_url, + account_type, entity_ig_role, entity_region_code, needs_review, + artist_id, brand_id, group_id, metadata, updated_at + FROM public.instagram_accounts + WHERE {where_sql} + ORDER BY updated_at DESC + LIMIT $4 OFFSET $5" + ), + list_values, + ); + let rows = db.query_all(list_stmt).await.map_err(AppError::DatabaseError)?; + let accounts = rows + .into_iter() + .map(row_to_account) + .collect::, AppError>>()?; + + Ok((total, accounts)) +} + +fn row_to_account(row: sea_orm::QueryResult) -> Result { + let id: Uuid = row.try_get("", "id").map_err(AppError::DatabaseError)?; + let updated_at: chrono::DateTime = row + .try_get("", "updated_at") + .map_err(AppError::DatabaseError)?; + Ok(InstagramAccountRow { + id: id.to_string(), + username: row + .try_get("", "username") + .map_err(AppError::DatabaseError)?, + name_en: row.try_get("", "name_en").map_err(AppError::DatabaseError)?, + name_ko: row.try_get("", "name_ko").map_err(AppError::DatabaseError)?, + display_name: row + .try_get("", "display_name") + .map_err(AppError::DatabaseError)?, + bio: row.try_get("", "bio").map_err(AppError::DatabaseError)?, + profile_image_url: row + .try_get("", "profile_image_url") + .map_err(AppError::DatabaseError)?, + account_type: row + .try_get("", "account_type") + .map_err(AppError::DatabaseError)?, + entity_ig_role: row + .try_get("", "entity_ig_role") + .map_err(AppError::DatabaseError)?, + entity_region_code: row + .try_get("", "entity_region_code") + .map_err(AppError::DatabaseError)?, + needs_review: row + .try_get("", "needs_review") + .map_err(AppError::DatabaseError)?, + artist_id: optional_uuid_string(&row, "artist_id")?, + brand_id: optional_uuid_string(&row, "brand_id")?, + group_id: optional_uuid_string(&row, "group_id")?, + metadata: row + .try_get::>("", "metadata") + .map_err(AppError::DatabaseError)?, + updated_at: updated_at.to_rfc3339(), + }) +} + +fn optional_uuid_string(row: &sea_orm::QueryResult, col: &str) -> Result, AppError> { + let value: Option = row.try_get("", col).map_err(AppError::DatabaseError)?; + Ok(value.map(|id| id.to_string())) +} + +async fn fetch_summary(db: &DatabaseConnection) -> AppResult { + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT + COUNT(*)::bigint AS total, + COUNT(*) FILTER (WHERE account_type = 'artist')::bigint AS type_artist, + COUNT(*) FILTER (WHERE account_type = 'brand')::bigint AS type_brand, + COUNT(*) FILTER (WHERE account_type = 'group')::bigint AS type_group, + COUNT(*) FILTER (WHERE account_type = 'other')::bigint AS type_other, + COUNT(*) FILTER (WHERE account_type = 'unknown')::bigint AS type_unknown, + COUNT(*) FILTER (WHERE entity_ig_role = 'primary')::bigint AS role_primary, + COUNT(*) FILTER (WHERE entity_ig_role = 'secondary')::bigint AS role_secondary, + COUNT(*) FILTER (WHERE entity_ig_role = 'regional')::bigint AS role_regional, + COUNT(*) FILTER (WHERE entity_ig_role = 'unknown')::bigint AS role_unknown, + COUNT(*) FILTER ( + WHERE metadata->'entity_enrichment'->'gemini_review'->>'status' = 'reviewed' + )::bigint AS status_reviewed, + COUNT(*) FILTER ( + WHERE metadata->'entity_enrichment'->'gemini_review'->>'status' = 'needs_human' + )::bigint AS status_needs_human, + COUNT(*) FILTER ( + WHERE metadata->'entity_enrichment'->'gemini_review'->>'status' = 'error' + )::bigint AS status_error, + COUNT(*) FILTER ( + WHERE metadata->'entity_enrichment'->'gemini_review'->>'status' = 'processing' + )::bigint AS status_processing + FROM public.instagram_accounts", + Vec::::new(), + ); + let row = db + .query_one(stmt) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::internal("summary query returned no row"))?; + + let total: i64 = row.try_get("", "total").map_err(AppError::DatabaseError)?; + let status_reviewed: i64 = row + .try_get("", "status_reviewed") + .map_err(AppError::DatabaseError)?; + let status_needs_human: i64 = row + .try_get("", "status_needs_human") + .map_err(AppError::DatabaseError)?; + let status_error: i64 = row + .try_get("", "status_error") + .map_err(AppError::DatabaseError)?; + let status_processing: i64 = row + .try_get("", "status_processing") + .map_err(AppError::DatabaseError)?; + let explicit = status_reviewed + status_needs_human + status_error + status_processing; + + let mut by_status = std::collections::HashMap::new(); + by_status.insert("reviewed".to_string(), status_reviewed); + by_status.insert("needs_human".to_string(), status_needs_human); + by_status.insert("error".to_string(), status_error); + by_status.insert("processing".to_string(), status_processing); + by_status.insert("pending".to_string(), (total - explicit).max(0)); + + let mut by_account_type = std::collections::HashMap::new(); + by_account_type.insert( + "artist".to_string(), + row.try_get("", "type_artist") + .map_err(AppError::DatabaseError)?, + ); + by_account_type.insert( + "brand".to_string(), + row.try_get("", "type_brand") + .map_err(AppError::DatabaseError)?, + ); + by_account_type.insert( + "group".to_string(), + row.try_get("", "type_group") + .map_err(AppError::DatabaseError)?, + ); + by_account_type.insert( + "other".to_string(), + row.try_get("", "type_other") + .map_err(AppError::DatabaseError)?, + ); + by_account_type.insert( + "unknown".to_string(), + row.try_get("", "type_unknown") + .map_err(AppError::DatabaseError)?, + ); + + let mut by_role = std::collections::HashMap::new(); + by_role.insert( + "primary".to_string(), + row.try_get("", "role_primary") + .map_err(AppError::DatabaseError)?, + ); + by_role.insert( + "secondary".to_string(), + row.try_get("", "role_secondary") + .map_err(AppError::DatabaseError)?, + ); + by_role.insert( + "regional".to_string(), + row.try_get("", "role_regional") + .map_err(AppError::DatabaseError)?, + ); + by_role.insert( + "unknown".to_string(), + row.try_get("", "role_unknown") + .map_err(AppError::DatabaseError)?, + ); + + Ok(InstagramAccountsSummary { + total, + by_status, + by_account_type, + by_role, + }) +} + +pub fn router(state: AppState, app_config: AppConfig) -> Router { + Router::new() + .route("/instagram-accounts", get(list_instagram_accounts)) + .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/api-server/src/domains/admin/handlers.rs b/packages/api-server/src/domains/admin/handlers.rs index 216e5e22..41eb7bf3 100644 --- a/packages/api-server/src/domains/admin/handlers.rs +++ b/packages/api-server/src/domains/admin/handlers.rs @@ -8,7 +8,7 @@ use crate::{app_state::AppState, config::AppConfig}; use super::{ badges, categories, curations, dashboard, editorial_article_chat, editorial_articles, - editorial_candidates, editorial_discovery_settings, editorial_pipeline_settings, + editorial_candidates, editorial_discovery_settings, entity_enrichment, editorial_pipeline_settings, editorial_recommendations, gemini_cost, magazine_sessions, monitoring, posts, solutions, spots, synonyms, verify_stats, }; @@ -58,6 +58,10 @@ pub fn router(state: AppState, app_config: AppConfig) -> Router { "/gemini-cost", gemini_cost::router(state.clone(), app_config.clone()), ) + .nest( + "/entity-enrichment", + entity_enrichment::router(state.clone(), app_config.clone()), + ) .nest( "/verify-stats", verify_stats::router(state.clone(), app_config.clone()), diff --git a/packages/api-server/src/domains/admin/mod.rs b/packages/api-server/src/domains/admin/mod.rs index f3db17f4..b75946bf 100644 --- a/packages/api-server/src/domains/admin/mod.rs +++ b/packages/api-server/src/domains/admin/mod.rs @@ -11,6 +11,7 @@ pub mod editorial_article_chat; pub mod editorial_articles; pub mod editorial_candidates; pub mod editorial_discovery_settings; +pub mod entity_enrichment; pub mod editorial_pipeline_settings; pub mod editorial_recommendations; pub mod gemini_cost; diff --git a/packages/web/app/api/admin/data-pipeline/instagram-accounts/route.ts b/packages/web/app/api/admin/data-pipeline/instagram-accounts/route.ts index a60c3c4e..43693457 100644 --- a/packages/web/app/api/admin/data-pipeline/instagram-accounts/route.ts +++ b/packages/web/app/api/admin/data-pipeline/instagram-accounts/route.ts @@ -1,11 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { checkIsAdmin } from "@/lib/supabase/admin"; -import { createAdminSupabaseClient } from "@/lib/supabase/admin-server"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { API_BASE_URL } from "@/lib/server-env"; -const DEFAULT_LIMIT = 25; -const MAX_LIMIT = 100; const PLATFORM = "entity_enrichment"; const DEFAULT_SETTINGS = { enabled: false, @@ -27,87 +24,89 @@ function kstDateString(date = new Date()): string { } export async function GET(req: NextRequest) { - const auth = await adminSession(); - if ("error" in auth) { - return NextResponse.json({ error: auth.error }, { status: auth.status }); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const supabase = createAdminSupabaseClient() as any; - const params = req.nextUrl.searchParams; - const limit = Math.min( - MAX_LIMIT, - Math.max(1, Number(params.get("limit") ?? DEFAULT_LIMIT)) - ); - const offset = Math.max(0, Number(params.get("offset") ?? 0)); - const accountType = params.get("account_type") ?? ""; - const role = params.get("entity_ig_role") ?? ""; - const search = params.get("search")?.trim() ?? ""; - const today = kstDateString(); - - let accountsQuery = supabase - .from("instagram_accounts") - .select( - "id,username,name_en,name_ko,display_name,bio,profile_image_url,account_type,entity_ig_role,entity_region_code,needs_review,artist_id,brand_id,group_id,metadata,updated_at", - { count: "exact" } - ) - .order("updated_at", { ascending: false }) - .range(offset, offset + limit - 1); + try { + const auth = await adminSession(); + if ("error" in auth) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + if (!API_BASE_URL) { + return NextResponse.json( + { error: "API_BASE_URL is not configured" }, + { status: 502 } + ); + } - if (accountType) - accountsQuery = accountsQuery.eq("account_type", accountType); - if (role) accountsQuery = accountsQuery.eq("entity_ig_role", role); - if (search) { - accountsQuery = accountsQuery.or( - `username.ilike.%${search}%,name_en.ilike.%${search}%,name_ko.ilike.%${search}%,display_name.ilike.%${search}%` - ); - } + const qs = req.nextUrl.searchParams.toString(); + const today = kstDateString(); - const [settingsRes, usageRes, accountsRes, statusRowsRes, typeRes, roleRes] = - await Promise.all([ + const [settingsRes, usageRes, dataRes] = await Promise.all([ fetchEntitySettings(auth.token), fetchEntityUsage(auth.token), - accountsQuery, - supabase - .from("instagram_accounts") - .select("metadata", { count: "exact" }), - supabase - .from("instagram_accounts") - .select("account_type", { count: "exact" }), - supabase - .from("instagram_accounts") - .select("entity_ig_role", { count: "exact" }), + fetch( + `${API_BASE_URL}/api/v1/admin/entity-enrichment/instagram-accounts${ + qs ? `?${qs}` : "" + }`, + { + headers: { Authorization: `Bearer ${auth.token}` }, + cache: "no-store", + } + ), ]); - const firstError = - accountsRes.error ?? statusRowsRes.error ?? typeRes.error ?? roleRes.error; - if (firstError) { - return NextResponse.json({ error: firstError.message }, { status: 500 }); - } + const dataText = await dataRes.text(); + if (!dataRes.ok) { + let message = `Upstream error: ${dataRes.status}`; + if (dataText) { + try { + const parsed = JSON.parse(dataText) as { error?: string | { message?: string } }; + if (typeof parsed.error === "string") { + message = parsed.error; + } else if ( + parsed.error && + typeof parsed.error === "object" && + parsed.error.message + ) { + message = parsed.error.message; + } + } catch { + message = dataText.slice(0, 500); + } + } + return NextResponse.json({ error: message }, { status: dataRes.status }); + } - return NextResponse.json({ - settings: settingsRes, - usage: usageRes ?? { - usage_date: today, - feature: PLATFORM, - model: "gemini-2.5-flash", - grounded_request_count: 0, - free_quota: settingsRes.daily_grounding_cap, - paid_overage_count: 0, - }, - accounts: accountsRes.data ?? [], - pagination: { - limit, - offset, - total: accountsRes.count ?? 0, - }, - summary: { - total: statusRowsRes.count ?? 0, - by_status: countByReviewStatus(statusRowsRes.data ?? []), - by_account_type: countBy(typeRes.data ?? [], "account_type"), - by_role: countBy(roleRes.data ?? [], "entity_ig_role"), - }, - }); + const data = dataText + ? (JSON.parse(dataText) as { + accounts: unknown[]; + pagination: { limit: number; offset: number; total: number }; + summary: Record; + }) + : { + accounts: [], + pagination: { limit: 25, offset: 0, total: 0 }, + summary: { total: 0, by_status: {}, by_account_type: {}, by_role: {} }, + }; + + return NextResponse.json({ + settings: settingsRes, + usage: usageRes ?? { + usage_date: today, + feature: PLATFORM, + model: "gemini-2.5-flash", + grounded_request_count: 0, + free_quota: settingsRes.daily_grounding_cap, + paid_overage_count: 0, + }, + accounts: data.accounts, + pagination: data.pagination, + summary: data.summary, + }); + } catch (error) { + console.error("[instagram-accounts] GET failed:", error); + const message = + error instanceof Error ? error.message : "Internal server error"; + return NextResponse.json({ error: message }, { status: 500 }); + } } async function adminSession() { @@ -179,30 +178,3 @@ async function fetchEntityUsage(token: string) { paid_overage_count: Math.max(0, (todayRow?.groundings ?? 0) - 1500), }; } - -function countBy(rows: Array>, key: string) { - return rows.reduce>((acc, row) => { - const value = String(row[key] ?? "unknown"); - acc[value] = (acc[value] ?? 0) + 1; - return acc; - }, {}); -} - -function countByReviewStatus(rows: Array>) { - return rows.reduce>((acc, row) => { - const status = reviewStatusFromMetadata(row["metadata"]); - acc[status] = (acc[status] ?? 0) + 1; - return acc; - }, {}); -} - -function reviewStatusFromMetadata(metadata: unknown) { - if (!metadata || typeof metadata !== "object") return "pending"; - const enrichment = (metadata as { entity_enrichment?: unknown }) - .entity_enrichment; - if (!enrichment || typeof enrichment !== "object") return "pending"; - const review = (enrichment as { gemini_review?: unknown }).gemini_review; - if (!review || typeof review !== "object") return "pending"; - const status = (review as { status?: unknown }).status; - return typeof status === "string" ? status : "pending"; -} From e2060ec38f3a0f41a01d9da51d48defb1154304d Mon Sep 17 00:00:00 2001 From: CIOI Date: Tue, 26 May 2026 00:13:41 +0900 Subject: [PATCH 2/6] docs(database): note api-server admin reads and #580 program links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #578 — enrichment RFC points to epic/issues; operating-model table links program tracking on GitHub instead of a separate monorepo file. Co-authored-by: Cursor --- docs/database/entity-enrichment-pipeline.md | 20 +++++++++++++++++--- docs/database/operating-model.md | 1 + 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/database/entity-enrichment-pipeline.md b/docs/database/entity-enrichment-pipeline.md index bfc1ea30..3cef6c73 100644 --- a/docs/database/entity-enrichment-pipeline.md +++ b/docs/database/entity-enrichment-pipeline.md @@ -215,8 +215,10 @@ Recommended implementation home: - candidate extraction and profile/Gemini enrichment: ai-server scheduler or worker, because `instagram.py`, Instaloader, Gemini usage, and existing scheduler patterns already live there; -- admin control and review: Next.js admin APIs/pages under `packages/web`; -- operation writes: service-role server-side only, with audit/provenance. +- admin control and review: Next.js admin pages; **operation DB reads via api-server** + (`GET /api/v1/admin/entity-enrichment/instagram-accounts`, #578) — not Vercel + `DATABASE_SERVICE_ROLE_KEY`; +- operation writes: service-role on api-server / ai-server only, with audit/provenance. ## Admin Dashboard Scope @@ -336,7 +338,19 @@ We want to use Gemini 2.5 Flash with Google Search grounding to enrich missing ` - First release should process both new tagged accounts and existing operation `instagram_accounts` that are missing names/images/review state. - Reuse the existing R2 public URL strategy for profile/logo images; do not add Supabase Storage for this flow. - Review operation RLS posture before exposing more audit/admin data. Current MCP advisory reports RLS disabled on `seaql_migrations`, `admin_audit_log`, and `post_magazine_events`. -``` + +## Program follow-up (text → FK-first) + +Ship scope for the pipeline itself is [#495](https://github.com/decodedcorp/decoded/issues/495) (closed, PR #544). Broader **entity ID migration** (app/search/write path) is tracked on GitHub only — not a separate file under `docs/database/`. + +| Item | GitHub | +| ---- | ------ | +| Program epic | [#580](https://github.com/decodedcorp/decoded/issues/580) | +| S1 audit & S2 plan | [#581](https://github.com/decodedcorp/decoded/issues/581) | +| Admin list api-server proxy | [#578](https://github.com/decodedcorp/decoded/issues/578) | +| Backfill queue PoC | [#582](https://github.com/decodedcorp/decoded/issues/582) | + +Sprint task ↔ issue mapping lives in vault `2026-S1` (not monorepo). S2 waves (Meilisearch, UI resolver, write path) are out of scope for this RFC — see epic #580. ## Documentation Checklist diff --git a/docs/database/operating-model.md b/docs/database/operating-model.md index aca718ab..800dda39 100644 --- a/docs/database/operating-model.md +++ b/docs/database/operating-model.md @@ -173,6 +173,7 @@ unset PRD_DB_URL | Supabase CLI 사용법 (link, push, gen types) | [`docs/database/04-supabase-cli-setup.md`](04-supabase-cli-setup.md) | | nightly drift CI 운영 (#373) | [`docs/database/drift-check.md`](drift-check.md) | | Entity enrichment RFC | [`docs/database/entity-enrichment-pipeline.md`](entity-enrichment-pipeline.md) | +| Entity ID migration program (issues only) | GitHub [#580](https://github.com/decodedcorp/decoded/issues/580) (+ #581, #582); sprint mapping in vault `2026-S1` | | PRD → dev 시드 자동화 스크립트 | [`scripts/seed-from-prod.sh`](../../scripts/seed-from-prod.sh) | | assets 프로젝트 설계 (#333) | [`docs/architecture/assets-project.md`](../architecture/assets-project.md) | | agent 짧은 요약 | [`docs/agent/database-summary.md`](../agent/database-summary.md) | From e91defd1fa5de4a2d5d82158109504611634ac35 Mon Sep 17 00:00:00 2001 From: CIOI Date: Tue, 26 May 2026 00:14:19 +0900 Subject: [PATCH 3/6] style(web): format instagram-accounts admin proxy route Co-authored-by: Cursor --- .../admin/data-pipeline/instagram-accounts/route.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/web/app/api/admin/data-pipeline/instagram-accounts/route.ts b/packages/web/app/api/admin/data-pipeline/instagram-accounts/route.ts index 43693457..0c2e4a74 100644 --- a/packages/web/app/api/admin/data-pipeline/instagram-accounts/route.ts +++ b/packages/web/app/api/admin/data-pipeline/instagram-accounts/route.ts @@ -58,7 +58,9 @@ export async function GET(req: NextRequest) { let message = `Upstream error: ${dataRes.status}`; if (dataText) { try { - const parsed = JSON.parse(dataText) as { error?: string | { message?: string } }; + const parsed = JSON.parse(dataText) as { + error?: string | { message?: string }; + }; if (typeof parsed.error === "string") { message = parsed.error; } else if ( @@ -84,7 +86,12 @@ export async function GET(req: NextRequest) { : { accounts: [], pagination: { limit: 25, offset: 0, total: 0 }, - summary: { total: 0, by_status: {}, by_account_type: {}, by_role: {} }, + summary: { + total: 0, + by_status: {}, + by_account_type: {}, + by_role: {}, + }, }; return NextResponse.json({ From 97c943c27a09a051227c7bd87841b7a28260ebac Mon Sep 17 00:00:00 2001 From: CIOI Date: Tue, 26 May 2026 00:14:25 +0900 Subject: [PATCH 4/6] style(api-server): rustfmt entity_enrichment admin module Co-authored-by: Cursor --- .../src/domains/admin/entity_enrichment.rs | 18 +++++++++++------- .../api-server/src/domains/admin/handlers.rs | 6 +++--- packages/api-server/src/domains/admin/mod.rs | 2 +- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/api-server/src/domains/admin/entity_enrichment.rs b/packages/api-server/src/domains/admin/entity_enrichment.rs index c3e0da8e..29a47ddb 100644 --- a/packages/api-server/src/domains/admin/entity_enrichment.rs +++ b/packages/api-server/src/domains/admin/entity_enrichment.rs @@ -84,10 +84,7 @@ pub async fn list_instagram_accounts( _user: axum::Extension, Query(query): Query, ) -> AppResult> { - let limit = query - .limit - .unwrap_or(DEFAULT_LIMIT) - .clamp(1, MAX_LIMIT); + let limit = query.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); let offset = query.offset.unwrap_or(0).max(0); let account_type = non_empty(query.account_type); let entity_ig_role = non_empty(query.entity_ig_role); @@ -206,7 +203,10 @@ async fn fetch_accounts( ), list_values, ); - let rows = db.query_all(list_stmt).await.map_err(AppError::DatabaseError)?; + let rows = db + .query_all(list_stmt) + .await + .map_err(AppError::DatabaseError)?; let accounts = rows .into_iter() .map(row_to_account) @@ -225,8 +225,12 @@ fn row_to_account(row: sea_orm::QueryResult) -> Result Date: Tue, 26 May 2026 00:15:21 +0900 Subject: [PATCH 5/6] fix(api-server): satisfy clippy on entity_enrichment SQL statements Co-authored-by: Cursor --- packages/api-server/src/domains/admin/entity_enrichment.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api-server/src/domains/admin/entity_enrichment.rs b/packages/api-server/src/domains/admin/entity_enrichment.rs index 29a47ddb..ae41c061 100644 --- a/packages/api-server/src/domains/admin/entity_enrichment.rs +++ b/packages/api-server/src/domains/admin/entity_enrichment.rs @@ -170,7 +170,7 @@ async fn fetch_accounts( let count_stmt = Statement::from_sql_and_values( DatabaseBackend::Postgres, - &format!( + format!( "SELECT COUNT(*)::bigint AS total FROM public.instagram_accounts WHERE {where_sql}" @@ -192,7 +192,7 @@ async fn fetch_accounts( let list_stmt = Statement::from_sql_and_values( DatabaseBackend::Postgres, - &format!( + format!( "SELECT id, username, name_en, name_ko, display_name, bio, profile_image_url, account_type, entity_ig_role, entity_region_code, needs_review, artist_id, brand_id, group_id, metadata, updated_at From 1404d265aa6b2236fc89c4969b036d1f12d045ce Mon Sep 17 00:00:00 2001 From: CIOI Date: Tue, 26 May 2026 00:30:16 +0900 Subject: [PATCH 6/6] test(api-server): unit tests for entity enrichment instagram-accounts admin API Covers ILIKE escaping, summary pending counts, account row mapping, and DB errors. Co-authored-by: Cursor --- .../src/domains/admin/entity_enrichment.rs | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/packages/api-server/src/domains/admin/entity_enrichment.rs b/packages/api-server/src/domains/admin/entity_enrichment.rs index ae41c061..8cd98199 100644 --- a/packages/api-server/src/domains/admin/entity_enrichment.rs +++ b/packages/api-server/src/domains/admin/entity_enrichment.rs @@ -391,3 +391,152 @@ pub fn router(state: AppState, app_config: AppConfig) -> Router { crate::middleware::auth_middleware, )) } + +#[cfg(test)] +#[allow(clippy::disallowed_methods)] +mod tests { + use super::*; + use crate::tests::fixtures; + use chrono::TimeZone; + use sea_orm::{DatabaseBackend, MockDatabase}; + use std::collections::BTreeMap; + + fn total_row(total: i64) -> BTreeMap { + let mut row = BTreeMap::new(); + row.insert("total".to_string(), Value::BigInt(Some(total))); + row + } + + fn summary_row( + total: i64, + status_reviewed: i64, + status_needs_human: i64, + status_error: i64, + status_processing: i64, + ) -> BTreeMap { + let mut row = BTreeMap::new(); + row.insert("total".to_string(), Value::BigInt(Some(total))); + row.insert("type_artist".to_string(), Value::BigInt(Some(1))); + row.insert("type_brand".to_string(), Value::BigInt(Some(2))); + row.insert("type_group".to_string(), Value::BigInt(Some(3))); + row.insert("type_other".to_string(), Value::BigInt(Some(4))); + row.insert("type_unknown".to_string(), Value::BigInt(Some(5))); + row.insert("role_primary".to_string(), Value::BigInt(Some(6))); + row.insert("role_secondary".to_string(), Value::BigInt(Some(7))); + row.insert("role_regional".to_string(), Value::BigInt(Some(8))); + row.insert("role_unknown".to_string(), Value::BigInt(Some(9))); + row.insert( + "status_reviewed".to_string(), + Value::BigInt(Some(status_reviewed)), + ); + row.insert( + "status_needs_human".to_string(), + Value::BigInt(Some(status_needs_human)), + ); + row.insert("status_error".to_string(), Value::BigInt(Some(status_error))); + row.insert( + "status_processing".to_string(), + Value::BigInt(Some(status_processing)), + ); + row + } + + fn instagram_account_row(username: &str, account_type: &str) -> BTreeMap { + let id = fixtures::test_uuid(90); + let updated_at = chrono::Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap(); + let mut row = BTreeMap::new(); + row.insert("id".to_string(), Value::Uuid(Some(Box::new(id)))); + row.insert( + "username".to_string(), + Value::String(Some(Box::new(username.to_string()))), + ); + row.insert("name_en".to_string(), Value::String(None)); + row.insert("name_ko".to_string(), Value::String(None)); + row.insert("display_name".to_string(), Value::String(None)); + row.insert("bio".to_string(), Value::String(None)); + row.insert("profile_image_url".to_string(), Value::String(None)); + row.insert( + "account_type".to_string(), + Value::String(Some(Box::new(account_type.to_string()))), + ); + row.insert( + "entity_ig_role".to_string(), + Value::String(Some(Box::new("primary".to_string()))), + ); + row.insert("entity_region_code".to_string(), Value::String(None)); + row.insert("needs_review".to_string(), Value::Bool(Some(false))); + row.insert("artist_id".to_string(), Value::Uuid(None)); + row.insert("brand_id".to_string(), Value::Uuid(None)); + row.insert("group_id".to_string(), Value::Uuid(None)); + row.insert( + "metadata".to_string(), + Value::Json(Some(Box::new(serde_json::json!({ + "entity_enrichment": { + "gemini_review": { "status": "reviewed" } + } + })))), + ); + row.insert( + "updated_at".to_string(), + Value::ChronoDateTimeUtc(Some(Box::new(updated_at))), + ); + row + } + + #[test] + fn escape_ilike_pattern_escapes_wildcards() { + assert_eq!(escape_ilike_pattern("a%b_c"), "%a\\%b\\_c%"); + assert_eq!(escape_ilike_pattern("plain"), "%plain%"); + } + + #[test] + fn non_empty_trims_and_drops_blank() { + assert_eq!(non_empty(Some(" ".into())), None); + assert_eq!(non_empty(Some("".into())), None); + assert_eq!( + non_empty(Some(" artist ".into())), + Some("artist".to_string()) + ); + } + + #[tokio::test] + async fn fetch_summary_pending_is_total_minus_explicit_statuses() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![summary_row(100, 10, 5, 2, 3)]]) + .into_connection(); + + let summary = fetch_summary(&db).await.expect("ok"); + assert_eq!(summary.total, 100); + assert_eq!(summary.by_status.get("reviewed"), Some(&10)); + assert_eq!(summary.by_status.get("pending"), Some(&80)); + assert_eq!(summary.by_account_type.get("artist"), Some(&1)); + assert_eq!(summary.by_role.get("primary"), Some(&6)); + } + + #[tokio::test] + async fn fetch_accounts_returns_total_and_mapped_rows() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![total_row(1)]]) + .append_query_results([vec![instagram_account_row("decoded.style", "brand")]]) + .into_connection(); + + let (total, accounts) = fetch_accounts(&db, 25, 0, None, None, None) + .await + .expect("ok"); + assert_eq!(total, 1); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].username, "decoded.style"); + assert_eq!(accounts[0].account_type, "brand"); + assert_eq!(accounts[0].id, fixtures::test_uuid(90).to_string()); + } + + #[tokio::test] + async fn fetch_accounts_db_error_on_count() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_errors(vec![sea_orm::DbErr::Custom("count failed".into())]) + .into_connection(); + + let result = fetch_accounts(&db, 25, 0, None, None, None).await; + assert!(result.is_err()); + } +}