pub mod client;
pub mod model;
pub mod repo;
pub mod routes;
use async_trait::async_trait;
use axum::Router;
use axum::routing::{get, post};
use oxipage_core::client::Client;
use oxipage_core::builder::{BuildExt, SearchDoc, StaticPage};
use oxipage_core::extension::{
CliCommand, CliHandler, CliSubcommand, Extension, ExtensionWizard, Lang, LobbyCard,
LobbyCardItem, Migration, SetupField, SetupFieldKind, SetupSaveHandler, SetupStep, StepOutcome,
VisibilityRule,
};
use oxipage_core::scheduler::ScheduledJob;
use oxipage_core::state::AppState;
use sqlx::SqlitePool;
use std::collections::BTreeMap;
use std::error::Error;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
struct ActivitySyncHandler;
impl CliHandler for ActivitySyncHandler {
fn run(
&self,
_args: BTreeMap<String, String>,
client: &Client,
) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + '_>> {
let client = client.clone();
Box::pin(async move {
let resp = client
.post_raw("/api/console/activity/sync", serde_json::json!({}))
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!("{}", serde_json::to_string_pretty(&resp)?);
Ok(())
})
}
}
pub struct ActivityExtension;
pub struct ActivitySyncJob;
#[async_trait]
impl ScheduledJob for ActivitySyncJob {
fn name(&self) -> &str {
"activity_sync"
}
async fn run(&self, ctx: &AppState) -> anyhow::Result<()> {
let client =
client::GithubClient::with_username(ctx.config.integrations.github_username())?;
if !client.enabled() {
tracing::debug!("activity sync skipped: GitHub username not configured");
return Ok(());
}
let events = client.fetch_public_events().await?;
let total = events.len();
let mut synced = 0usize;
for event in events {
if event.kind.trim().is_empty()
|| event.repo.name.trim().is_empty()
|| event.created_at.trim().is_empty()
{
continue;
}
match repo::upsert(&ctx.db, &event.into_input()).await {
Ok(_) => synced += 1,
Err(e) => tracing::warn!(error = ?e, "activity upsert failed"),
}
}
tracing::info!(total, synced, "activity sync tick completed");
Ok(())
}
}
#[async_trait]
impl Extension for ActivityExtension {
fn id(&self) -> &'static str {
"activity"
}
fn table_names(&self) -> Vec<&'static str> {
vec!["activity_event"]
}
fn display_name(&self, lang: Lang) -> String {
match lang {
Lang::Ko => "활동".to_string(),
Lang::En => "Activity".to_string(),
}
}
fn migrations(&self) -> Vec<Migration> {
vec![Migration {
version: 1,
name: "init",
sql: include_str!("../migrations/0001_init.sql"),
}]
}
fn routes(&self) -> Router<AppState> {
Router::new()
.route("/", get(routes::list))
.route("/webhook", post(routes::webhook))
.route("/sync", post(routes::sync))
}
async fn lobby_summary(&self, ctx: &AppState) -> Option<LobbyCard> {
let events = repo::list(&ctx.db, None, 5).await.ok()?;
if events.is_empty() {
return None;
}
let items = events
.into_iter()
.map(|event| LobbyCardItem {
title: event.summary,
url: event.url,
})
.collect();
Some(LobbyCard {
id: self.id().to_string(),
items,
})
}
fn background_jobs(&self) -> Vec<Arc<dyn ScheduledJob>> {
vec![Arc::new(ActivitySyncJob)]
}
fn cli_commands(&self) -> Vec<CliCommand> {
vec![CliCommand {
name: "activity",
about: "Manage activity feed",
subcommands: vec![CliSubcommand {
name: "sync",
about: "Trigger activity sync from GitHub",
args: vec![],
handler: Some(Arc::new(ActivitySyncHandler)),
}],
}]
}
fn setup_wizard(&self) -> Option<ExtensionWizard> {
Some(ExtensionWizard {
steps: vec![
SetupStep {
id: "activity_github",
title_ko: "GitHub 사용자명",
title_en: "GitHub username",
description_ko: "활동 동기화에 사용할 GitHub 사용자명 (선택)",
description_en: "GitHub username for activity sync (optional)",
fields: vec![SetupField {
name: "github_username",
label_ko: "GitHub 사용자명",
label_en: "GitHub username",
kind: SetupFieldKind::Text,
required: false,
placeholder_ko: None,
placeholder_en: None,
}],
save_handler: Arc::new(ActivityGithubSave),
prefill: BTreeMap::new(),
visible_when: None,
},
SetupStep {
id: "activity_sync",
title_ko: "활동 동기화",
title_en: "Sync activity",
description_ko: "GitHub 공개 활동을 지금 가져옵니다",
description_en: "Fetch public GitHub activity now",
fields: vec![],
save_handler: Arc::new(ActivitySyncSave),
prefill: BTreeMap::new(),
visible_when: Some(VisibilityRule::FieldNotEmpty {
step_id: "activity_github",
field: "github_username",
}),
},
],
})
}
}
struct ActivityGithubSave;
#[async_trait]
impl SetupSaveHandler for ActivityGithubSave {
async fn save(
&self,
_ctx: &AppState,
form: &serde_json::Map<String, serde_json::Value>,
) -> anyhow::Result<StepOutcome> {
if let Some(v) = form.get("github_username").and_then(|x| x.as_str())
&& !v.is_empty()
{
unsafe {
std::env::set_var("OXIPAGE_GITHUB_USERNAME", v);
}
}
Ok(StepOutcome::from_form(form))
}
}
struct ActivitySyncSave;
#[async_trait]
impl SetupSaveHandler for ActivitySyncSave {
async fn save(
&self,
ctx: &AppState,
_form: &serde_json::Map<String, serde_json::Value>,
) -> anyhow::Result<StepOutcome> {
let client = client::GithubClient::from_env()?;
let mut synced = 0u32;
if client.enabled()
&& let Ok(events) = client.fetch_public_events().await {
for event in events {
if event.kind.trim().is_empty()
|| event.repo.name.trim().is_empty()
|| event.created_at.trim().is_empty()
{
continue;
}
if repo::upsert(&ctx.db, &event.into_input()).await.is_ok() {
synced += 1;
}
}
}
let mut m = serde_json::Map::new();
m.insert("synced".into(), synced.to_string().into());
Ok(StepOutcome { values: m })
}
}
impl BuildExt for ActivityExtension {
fn ext_id(&self) -> &'static str {
"activity"
}
fn build_pages(
&self,
db: &SqlitePool,
rt: &tokio::runtime::Handle,
) -> Result<Vec<StaticPage>, Box<dyn Error + Send + Sync>> {
let _events: Vec<model::ActivityEvent> = rt.block_on(repo::list(db, None, 200))?;
Ok(vec![StaticPage {
path: "activity/index.html".into(),
content: r#"<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Activity</title><link rel="canonical" href="/activity/"></head><body><div id="root"></div><script src="/assets/index.js"></script></body></html>"#.to_string(),
}])
}
fn build_data(
&self,
db: &SqlitePool,
rt: &tokio::runtime::Handle,
) -> Result<Box<dyn erased_serde::Serialize + Send>, Box<dyn Error + Send + Sync>> {
let events: Vec<model::ActivityEvent> = rt.block_on(repo::list(db, None, 200))?;
Ok(Box::new(events))
}
fn build_search_docs(
&self,
_db: &SqlitePool,
_rt: &tokio::runtime::Handle,
) -> Result<Vec<SearchDoc>, Box<dyn Error + Send + Sync>> {
Ok(vec![])
}
}