feat: Site Announcements routes migrated to Rust

- New SiteAnnouncement model (site_announcement.rs)
- Route handlers: list (with siteId/platform/read/status filters),
  mark read, mark all read, delete all
- In-memory status filtering for active/expired/dismissed (same as Node.js)
- Uses existing site_announcements table from migration
- 18/18 tests pass
This commit is contained in:
boos
2026-05-17 13:34:53 +08:00
parent 2793a7990d
commit 9329248e59
4 changed files with 202 additions and 0 deletions
+1
View File
@@ -6,5 +6,6 @@ pub mod event;
pub mod route_channel; pub mod route_channel;
pub mod setting; pub mod setting;
pub mod site; pub mod site;
pub mod site_announcement;
pub mod token_route; pub mod token_route;
pub mod user; pub mod user;
@@ -0,0 +1,24 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// Site-level announcements (notifications from upstream providers).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct SiteAnnouncement {
pub id: i64,
pub site_id: i64,
pub platform: String,
pub source_key: String,
pub title: String,
pub content: String,
pub level: Option<String>,
pub source_url: Option<String>,
pub starts_at: Option<String>,
pub ends_at: Option<String>,
pub upstream_created_at: Option<String>,
pub upstream_updated_at: Option<String>,
pub first_seen_at: Option<String>,
pub last_seen_at: Option<String>,
pub read_at: Option<String>,
pub dismissed_at: Option<String>,
pub raw_payload: Option<String>,
}
+2
View File
@@ -8,6 +8,7 @@ mod comfyui;
mod events; mod events;
mod health; mod health;
mod proxy; mod proxy;
mod site_announcements;
pub fn build_router(state: AppState) -> Router { pub fn build_router(state: AppState) -> Router {
Router::new() Router::new()
@@ -18,5 +19,6 @@ pub fn build_router(state: AppState) -> Router {
.merge(auth::routes()) .merge(auth::routes())
.merge(events::routes()) .merge(events::routes())
.merge(checkin::routes()) .merge(checkin::routes())
.merge(site_announcements::routes())
.with_state(state) .with_state(state)
} }
+175
View File
@@ -0,0 +1,175 @@
// Site Announcements routes — upstream provider notifications per site
use crate::{
db::models::site_announcement::SiteAnnouncement,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, Query, State},
routing::{delete, get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
#[derive(Debug, Deserialize)]
pub struct ListAnnouncementsQuery {
limit: Option<i64>,
offset: Option<i64>,
site_id: Option<i64>,
platform: Option<String>,
read: Option<String>, // "true" | "false"
status: Option<String>, // "active" | "expired" | "dismissed"
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/site-announcements", get(list_announcements).delete(delete_all))
.route("/api/site-announcements/{id}/read", post(mark_read))
.route("/api/site-announcements/read-all", post(mark_all_read))
}
/// GET /api/site-announcements — list announcements with optional filters
async fn list_announcements(
State(state): State<AppState>,
Query(q): Query<ListAnnouncementsQuery>,
) -> AppResult<Json<Value>> {
info!("Listing site announcements");
let limit = q.limit.unwrap_or(50).clamp(1, 500);
let offset = q.offset.unwrap_or(0).max(0);
let mut sql = "SELECT * FROM site_announcements WHERE 1=1".to_string();
let mut params: Vec<String> = vec![];
let mut bind_idx = 0;
if let Some(site_id) = q.site_id {
bind_idx += 1;
sql.push_str(&format!(" AND site_id = ?{}", bind_idx));
params.push(site_id.to_string());
}
if let Some(ref platform) = q.platform {
if !platform.is_empty() {
bind_idx += 1;
sql.push_str(&format!(" AND platform = ?{}", bind_idx));
params.push(platform.clone());
}
}
if let Some(ref r) = q.read {
if r == "true" {
sql.push_str(" AND read_at IS NOT NULL");
} else if r == "false" {
sql.push_str(" AND read_at IS NULL");
}
}
// status filter: active = NOT dismissed AND (ends_at IS NULL OR ends_at > now)
// We fetch all and apply status filter in Rust for simplicity
// (the "expired" vs "active" logic references ends_at which is a datetime comparison)
sql.push_str(" ORDER BY first_seen_at DESC");
let mut query = sqlx::query_as::<_, SiteAnnouncement>(&sql);
for p in &params {
query = query.bind(p);
}
let announcements: Vec<SiteAnnouncement> = query.fetch_all(&*state.db).await?;
// Apply status filter in-memory (matches Node.js behaviour)
let filtered = if let Some(ref status) = q.status {
let now = chrono::Utc::now().timestamp_millis() as i64;
let status = status.trim().to_lowercase();
announcements.into_iter().filter(|a| {
if status == "dismissed" {
a.dismissed_at.is_some()
} else if status == "expired" {
a.dismissed_at.is_none() && a.ends_at.as_ref().map_or(false, |e| {
parse_dt_millis(e) < now
})
} else if status == "active" {
a.dismissed_at.is_none() && a.ends_at.as_ref().map_or(true, |e| {
parse_dt_millis(e) >= now
})
} else {
true
}
}).collect()
} else {
announcements
};
// Apply pagination in-memory
let paginated: Vec<Value> = filtered
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.map(|a| {
json!({
"id": a.id,
"siteId": a.site_id,
"platform": a.platform,
"sourceKey": a.source_key,
"title": a.title,
"content": a.content,
"level": a.level,
"sourceUrl": a.source_url,
"startsAt": a.starts_at,
"endsAt": a.ends_at,
"upstreamCreatedAt": a.upstream_created_at,
"upstreamUpdatedAt": a.upstream_updated_at,
"firstSeenAt": a.first_seen_at,
"lastSeenAt": a.last_seen_at,
"readAt": a.read_at,
"dismissedAt": a.dismissed_at,
"rawPayload": a.raw_payload,
})
})
.collect();
Ok(Json(Value::Array(paginated)))
}
/// POST /api/site-announcements/{id}/read
async fn mark_read(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Marking site announcement {} as read", id);
sqlx::query("UPDATE site_announcements SET read_at = datetime('now') WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
/// POST /api/site-announcements/read-all
async fn mark_all_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Marking all site announcements as read");
sqlx::query("UPDATE site_announcements SET read_at = datetime('now') WHERE read_at IS NULL")
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
/// DELETE /api/site-announcements
async fn delete_all(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Deleting all site announcements");
sqlx::query("DELETE FROM site_announcements")
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
/// Parse a datetime string like "2026-05-15 10:30:00" or ISO to milliseconds since epoch.
fn parse_dt_millis(s: &str) -> i64 {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%d %H:%M:%S") {
dt.and_utc().timestamp_millis()
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s.trim()) {
dt.timestamp_millis()
} else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%dT%H:%M:%S") {
dt.and_utc().timestamp_millis()
} else {
0
}
}