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:
asepharyana
2026-07-04 09:10:32 +07:00
parent efb1f4e4a5
commit e73abf596d
3 changed files with 59 additions and 3 deletions
+1
View File
@@ -1883,6 +1883,7 @@ name = "shared-types"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
@@ -5,3 +5,4 @@ edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+57 -3
View File
@@ -1,3 +1,4 @@
use serde::de::{self, DeserializeOwned, Deserializer};
use serde::{Deserialize, Serialize};
// ── AI Status ─────────────────────────────────────────────
@@ -93,6 +94,50 @@ pub struct ChannelRef {
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 ────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MessageRecord {
@@ -121,13 +166,19 @@ pub struct MessageRecord {
pub ai_severity: Option<AiSeverity>,
#[serde(skip_serializing_if = "Option::is_none")]
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>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_moderation_score: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
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>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_recommended_action: Option<AiRecommendedAction>,
@@ -135,7 +186,10 @@ pub struct MessageRecord {
pub ai_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
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>,
}