// Feedback Hub client — forwards user-submitted feedback to the central // feedback-api service. Routed through Rust (not direct fetch) so that: // - CORS is bypassed (Tauri origin is not whitelisted server-side by design) // - The exact payload leaving the machine is auditable in a single place // - The pattern matches the other outbound calls (OAuth, license, updater) // // The feedback-api contract is documented in // `la-compagnie-maximus/docs/feedback-hub-ops.md`. The server silently drops // any context key outside its whitelist, so this module only sends the // fields declared in `Context` below. use serde::{Deserialize, Serialize}; use std::time::Duration; fn feedback_endpoint() -> String { std::env::var("FEEDBACK_HUB_URL") .unwrap_or_else(|_| "https://feedback.lacompagniemaximus.com".to_string()) } /// Context payload sent with a feedback submission. Keys MUST match the /// server whitelist in `feedback-api/index.js` — unknown keys are dropped /// silently. Each field is capped at 500 chars server-side. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct FeedbackContext { #[serde(skip_serializing_if = "Option::is_none")] pub page: Option, #[serde(skip_serializing_if = "Option::is_none")] pub locale: Option, #[serde(skip_serializing_if = "Option::is_none")] pub theme: Option, #[serde(skip_serializing_if = "Option::is_none")] pub viewport: Option, #[serde(rename = "userAgent", skip_serializing_if = "Option::is_none")] pub user_agent: Option, #[serde(skip_serializing_if = "Option::is_none")] pub timestamp: Option, } #[derive(Debug, Serialize)] struct FeedbackPayload<'a> { app_id: &'a str, content: &'a str, #[serde(skip_serializing_if = "Option::is_none")] user_id: Option, #[serde(skip_serializing_if = "Option::is_none")] context: Option, } #[derive(Debug, Serialize)] pub struct FeedbackSuccess { pub id: String, pub created_at: String, } #[derive(Debug, Deserialize)] struct FeedbackResponse { id: String, created_at: String, } /// Return a composed User-Agent string for the context payload, e.g. /// `"Simpl'Résultat/0.8.1 (linux)"`. Uses std::env::consts::OS so we don't /// pull in an extra Tauri plugin just for this. #[tauri::command] pub fn get_feedback_user_agent(app: tauri::AppHandle) -> String { let version = app.package_info().version.to_string(); let os = std::env::consts::OS; format!("Simpl'Résultat/{} ({})", version, os) } /// Submit a feedback to the Feedback Hub. Error strings are stable codes /// ("invalid", "rate_limit", "server_error", "network_error") that the /// frontend maps to i18n messages. #[tauri::command] pub async fn send_feedback( content: String, user_id: Option, context: Option, ) -> Result { let trimmed = content.trim(); if trimmed.is_empty() { return Err("invalid".to_string()); } let payload = FeedbackPayload { app_id: "simpl-resultat", content: trimmed, user_id, context, }; let client = reqwest::Client::builder() .timeout(Duration::from_secs(15)) .build() .map_err(|_| "network_error".to_string())?; let url = format!("{}/api/feedback", feedback_endpoint()); let res = client .post(&url) .json(&payload) .send() .await .map_err(|_| "network_error".to_string())?; match res.status().as_u16() { 201 => { let body: FeedbackResponse = res .json() .await .map_err(|_| "server_error".to_string())?; Ok(FeedbackSuccess { id: body.id, created_at: body.created_at, }) } 400 => Err("invalid".to_string()), 429 => Err("rate_limit".to_string()), _ => Err("server_error".to_string()), } } #[cfg(test)] mod tests { use super::*; #[test] fn context_skips_none_fields() { let ctx = FeedbackContext { page: Some("/settings".to_string()), locale: Some("fr".to_string()), theme: None, viewport: None, user_agent: None, timestamp: None, }; let json = serde_json::to_value(&ctx).unwrap(); let obj = json.as_object().unwrap(); assert_eq!(obj.len(), 2); assert!(obj.contains_key("page")); assert!(obj.contains_key("locale")); } #[test] fn context_serializes_user_agent_camelcase() { let ctx = FeedbackContext { user_agent: Some("Simpl'Résultat/0.8.1 (linux)".to_string()), ..Default::default() }; let json = serde_json::to_string(&ctx).unwrap(); assert!(json.contains("\"userAgent\"")); assert!(!json.contains("\"user_agent\"")); } #[tokio::test] async fn empty_content_is_rejected_locally() { let res = send_feedback(" \n\t".to_string(), None, None).await; assert_eq!(res.unwrap_err(), "invalid"); } }