Closes #67 Add opt-in Feedback Hub widget integrated into the Settings Logs card. Routes through a Rust command to bypass CORS and centralize privacy audit. First submission triggers a one-time consent dialog; three opt-in checkboxes (context, logs, identify with Maximus account) all unchecked by default. Wording and payload follow the cross-app conventions in la-compagnie-maximus/docs/feedback-hub-ops.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
5.1 KiB
Rust
159 lines
5.1 KiB
Rust
// 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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub locale: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub theme: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub viewport: Option<String>,
|
|
#[serde(rename = "userAgent", skip_serializing_if = "Option::is_none")]
|
|
pub user_agent: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub timestamp: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct FeedbackPayload<'a> {
|
|
app_id: &'a str,
|
|
content: &'a str,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
user_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
context: Option<FeedbackContext>,
|
|
}
|
|
|
|
#[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<String>,
|
|
context: Option<FeedbackContext>,
|
|
) -> Result<FeedbackSuccess, String> {
|
|
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");
|
|
}
|
|
}
|