fix(rust): match main's drizzle-style camelCase response shape

Frontend reads main's drizzle-generated camelCase fields (siteId,
accessToken, oauthProvider, etc.) and a nested `site: {...}` on each
account row. Rust models were serializing snake_case with no nested
site, so PUT /api/sites/undefined/disabled-models 404s, account.site?.id
is undefined everywhere, and similar shape mismatches break Accounts,
Sites, Tokens, and Dashboard pages.

- Add #[serde(rename_all = "camelCase")] to every model struct in
  src/db/models/* (Account, AccountToken, Site, User, Event,
  TokenRoute, RouteChannel, BackgroundTask, DownstreamApiKey,
  Organization/Member/Invitation, SiteAnnouncement)
- list_accounts now nests `site: {...}` per row, matching main's
  `{...account, site: {...}}` shape from accountsOverviewService
- admin user-detail endpoint emits camelCase keys (totalRequests,
  totalCost, last7DaysCost, recentLogs, promptTokens, completionTokens,
  totalTokens, createdAt) to match the frontend
- routes refresh endpoint now emits both affected_routes and
  affectedRoutes for forward compat

Co-Authored-By: Kiro <noreply@kiro.dev>
This commit is contained in:
boos
2026-05-20 07:21:40 +08:00
parent 9c0d4ec69f
commit 7c9a1e2cc4
13 changed files with 49 additions and 12 deletions
+2
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Represents an account bound to a site. /// Represents an account bound to a site.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Account { pub struct Account {
pub id: i64, pub id: i64,
pub site_id: i64, pub site_id: i64,
@@ -30,6 +31,7 @@ pub struct Account {
/// Represents an account token (from account_tokens table). /// Represents an account token (from account_tokens table).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct AccountToken { pub struct AccountToken {
pub id: i64, pub id: i64,
pub account_id: i64, pub account_id: i64,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Background task for tracking async operations (deployments, syncs, etc.) /// Background task for tracking async operations (deployments, syncs, etc.)
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundTask { pub struct BackgroundTask {
pub id: String, pub id: String,
pub task_type: String, pub task_type: String,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Represents a downstream API key for external consumers. /// Represents a downstream API key for external consumers.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct DownstreamApiKey { pub struct DownstreamApiKey {
pub id: i64, pub id: i64,
pub name: String, pub name: String,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// System event for admin notifications. /// System event for admin notifications.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Event { pub struct Event {
pub id: i64, pub id: i64,
#[sqlx(rename = "type")] #[sqlx(rename = "type")]
+3
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Organization for multi-tenant setup. /// Organization for multi-tenant setup.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Organization { pub struct Organization {
pub id: i64, pub id: i64,
pub name: String, pub name: String,
@@ -15,6 +16,7 @@ pub struct Organization {
/// Member of an organization. /// Member of an organization.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct OrganizationMember { pub struct OrganizationMember {
pub id: i64, pub id: i64,
pub org_id: i64, pub org_id: i64,
@@ -26,6 +28,7 @@ pub struct OrganizationMember {
/// Invitation to join an organization. /// Invitation to join an organization.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Invitation { pub struct Invitation {
pub id: i64, pub id: i64,
pub org_id: i64, pub org_id: i64,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Represents a route channel linking routes, accounts, and tokens. /// Represents a route channel linking routes, accounts, and tokens.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct RouteChannel { pub struct RouteChannel {
pub id: i64, pub id: i64,
pub route_id: i64, pub route_id: i64,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Represents a site (AI provider endpoint). /// Represents a site (AI provider endpoint).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Site { pub struct Site {
pub id: i64, pub id: i64,
pub name: String, pub name: String,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Site-level announcements (notifications from upstream providers). /// Site-level announcements (notifications from upstream providers).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct SiteAnnouncement { pub struct SiteAnnouncement {
pub id: i64, pub id: i64,
pub site_id: i64, pub site_id: i64,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Represents a token route (model routing rule). /// Represents a token route (model routing rule).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct TokenRoute { pub struct TokenRoute {
pub id: i64, pub id: i64,
pub model_pattern: String, pub model_pattern: String,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// A platform user (admin or regular). /// A platform user (admin or regular).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct User { pub struct User {
pub id: i64, pub id: i64,
pub username: String, pub username: String,
+24 -2
View File
@@ -109,9 +109,31 @@ pub async fn list_accounts(
.fetch_all(&*state.db) .fetch_all(&*state.db)
.await?; .await?;
// Nest the site object on each account so the frontend (which expects
// main's drizzle-style `{...account, site: {...}}` shape) can read
// `account.site.id`, `account.site.name`, `account.site.status`.
let site_by_id: std::collections::HashMap<i64, &Site> =
sites.iter().map(|s| (s.id, s)).collect();
let enriched: Vec<Value> = accounts
.iter()
.map(|account| {
let mut row = serde_json::to_value(account).unwrap_or(json!({}));
if let Some(site) = site_by_id.get(&account.site_id) {
if let Value::Object(map) = &mut row {
map.insert(
"site".to_string(),
serde_json::to_value(site).unwrap_or(Value::Null),
);
}
}
row
})
.collect();
Ok(Json(json!({ Ok(Json(json!({
"data": accounts, "data": enriched,
"accounts": accounts, "accounts": enriched,
"sites": sites, "sites": sites,
"generatedAt": chrono::Utc::now().to_rfc3339(), "generatedAt": chrono::Utc::now().to_rfc3339(),
}))) })))
+4 -2
View File
@@ -236,7 +236,8 @@ pub async fn refresh_route(
return Ok(Json(json!({ return Ok(Json(json!({
"data": { "data": {
"refreshed": true, "refreshed": true,
"affected_routes": result.rows_affected() "affected_routes": result.rows_affected(),
"affectedRoutes": result.rows_affected()
} }
}))); })));
} }
@@ -251,7 +252,8 @@ pub async fn refresh_route(
Ok(Json(json!({ Ok(Json(json!({
"data": { "data": {
"refreshed": true, "refreshed": true,
"affected_routes": result.rows_affected() "affected_routes": result.rows_affected(),
"affectedRoutes": result.rows_affected()
} }
}))) })))
} }
+8 -8
View File
@@ -341,22 +341,22 @@ pub async fn get_user_usage(
json!({ json!({
"id": row.get::<i64, _>("id"), "id": row.get::<i64, _>("id"),
"model": row.get::<Option<String>, _>("model"), "model": row.get::<Option<String>, _>("model"),
"prompt_tokens": row.get::<Option<i64>, _>("prompt_tokens").unwrap_or(0), "promptTokens": row.get::<Option<i64>, _>("prompt_tokens").unwrap_or(0),
"completion_tokens": row.get::<Option<i64>, _>("completion_tokens").unwrap_or(0), "completionTokens": row.get::<Option<i64>, _>("completion_tokens").unwrap_or(0),
"total_tokens": row.get::<Option<i64>, _>("total_tokens").unwrap_or(0), "totalTokens": row.get::<Option<i64>, _>("total_tokens").unwrap_or(0),
"cost": row.get::<Option<f64>, _>("cost").unwrap_or(0.0), "cost": row.get::<Option<f64>, _>("cost").unwrap_or(0.0),
"created_at": row.get::<Option<String>, _>("created_at"), "createdAt": row.get::<Option<String>, _>("created_at"),
}) })
}) })
.collect(); .collect();
Ok(Json(json!({ Ok(Json(json!({
"data": { "data": {
"total_requests": stats.0, "totalRequests": stats.0,
"total_cost": stats.1, "totalCost": stats.1,
"last_7_days_cost": last_7_days_cost, "last7DaysCost": last_7_days_cost,
"trend": trend.into_iter().map(|t| json!({ "day": t.0, "requests": t.1, "cost": t.2 })).collect::<Vec<_>>(), "trend": trend.into_iter().map(|t| json!({ "day": t.0, "requests": t.1, "cost": t.2 })).collect::<Vec<_>>(),
"recent_logs": recent_logs, "recentLogs": recent_logs,
} }
}))) })))
} }