fix(fe): handle JSON-stringified fields from backend in message deserialization
Backend returns metadata, ai_moderation_flags, and ai_categories as raw JSON strings (PostgreSQL JSONB cast to string). Frontend expected parsed structs/arrays, causing serde to fail silently — entire PageResult parse failed and user saw empty message list with no error indicator. Add custom deserialize_with handler (from_json_string_or_value) that transparently handles null / direct value / JSON-string cases for the three affected fields.
This commit is contained in:
Generated
+1
@@ -1883,6 +1883,7 @@ name = "shared-types"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use serde::de::{self, DeserializeOwned, Deserializer};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ── AI Status ─────────────────────────────────────────────
|
// ── AI Status ─────────────────────────────────────────────
|
||||||
@@ -93,6 +94,50 @@ pub struct ChannelRef {
|
|||||||
pub thread_name: Option<String>,
|
pub thread_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helpers: deserialize JSON-string fields ────────────────
|
||||||
|
//
|
||||||
|
// The backend stores certain fields as raw JSON strings in PostgreSQL.
|
||||||
|
// The JSON response therefore contains *stringified* JSON for these fields
|
||||||
|
// (e.g. `"metadata":"{\"stickers\":[]}"`) instead of the actual JSON value.
|
||||||
|
// These helpers transparently parse the string when present, so the frontend
|
||||||
|
// works with the native Rust type regardless of whether the backend ships
|
||||||
|
// a parsed value or a stringified one.
|
||||||
|
|
||||||
|
/// Deserialize a `T` from a JSON value that may be:
|
||||||
|
/// - `null` → `None`
|
||||||
|
/// - a plain JSON value → `Some(T)` (direct serde)
|
||||||
|
/// - a JSON *string* whose *contents* are JSON for `T`
|
||||||
|
fn from_json_string_or_value<'de, T, D>(d: D) -> Result<Option<T>, D::Error>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
// Intermediate Value to distinguish null / object / array / string
|
||||||
|
let v = Option::<serde_json::Value>::deserialize(d)?;
|
||||||
|
match v {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(serde_json::Value::String(s)) => {
|
||||||
|
serde_json::from_str(&s).map(Some).map_err(de::Error::custom)
|
||||||
|
}
|
||||||
|
Some(json) => serde_json::from_value(json).map(Some).map_err(de::Error::custom),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concrete wrapper for `metadata: Option<MessageMetadata>`.
|
||||||
|
pub(crate) fn deser_msg_meta<'de, D: Deserializer<'de>>(
|
||||||
|
d: D,
|
||||||
|
) -> Result<Option<MessageMetadata>, D::Error> {
|
||||||
|
from_json_string_or_value(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concrete wrapper for `Option<Vec<String>>` fields
|
||||||
|
/// (ai_moderation_flags, ai_categories, etc.).
|
||||||
|
pub(crate) fn deser_str_vec<'de, D: Deserializer<'de>>(
|
||||||
|
d: D,
|
||||||
|
) -> Result<Option<Vec<String>>, D::Error> {
|
||||||
|
from_json_string_or_value(d)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Message Record ────────────────────────────────────────
|
// ── Message Record ────────────────────────────────────────
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct MessageRecord {
|
pub struct MessageRecord {
|
||||||
@@ -121,13 +166,19 @@ pub struct MessageRecord {
|
|||||||
pub ai_severity: Option<AiSeverity>,
|
pub ai_severity: Option<AiSeverity>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub ai_confidence: Option<f64>,
|
pub ai_confidence: Option<f64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(
|
||||||
|
deserialize_with = "deser_str_vec",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
pub ai_moderation_flags: Option<Vec<String>>,
|
pub ai_moderation_flags: Option<Vec<String>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub ai_moderation_score: Option<f64>,
|
pub ai_moderation_score: Option<f64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub ai_analysis: Option<String>,
|
pub ai_analysis: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(
|
||||||
|
deserialize_with = "deser_str_vec",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
pub ai_categories: Option<Vec<String>>,
|
pub ai_categories: Option<Vec<String>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub ai_recommended_action: Option<AiRecommendedAction>,
|
pub ai_recommended_action: Option<AiRecommendedAction>,
|
||||||
@@ -135,7 +186,10 @@ pub struct MessageRecord {
|
|||||||
pub ai_error: Option<String>,
|
pub ai_error: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub ai_analyzed_at: Option<i64>,
|
pub ai_analyzed_at: Option<i64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(
|
||||||
|
deserialize_with = "deser_msg_meta",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
pub metadata: Option<MessageMetadata>,
|
pub metadata: Option<MessageMetadata>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user