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 setting;
|
||||
pub mod site;
|
||||
pub mod site_announcement;
|
||||
pub mod token_route;
|
||||
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 health;
|
||||
mod proxy;
|
||||
mod site_announcements;
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
@@ -18,5 +19,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(auth::routes())
|
||||
.merge(events::routes())
|
||||
.merge(checkin::routes())
|
||||
.merge(site_announcements::routes())
|
||||
.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