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:
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ¶ms {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user