From e73abf596d6444b529755cb269cb32ae71752752 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sat, 4 Jul 2026 09:10:32 +0700 Subject: [PATCH] fix(fe): handle JSON-stringified fields from backend in message deserialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- services/frontend/Cargo.lock | 1 + services/frontend/shared-types/Cargo.toml | 1 + services/frontend/shared-types/src/message.rs | 60 ++++++++++++++++++- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/services/frontend/Cargo.lock b/services/frontend/Cargo.lock index 27151bc..3407567 100644 --- a/services/frontend/Cargo.lock +++ b/services/frontend/Cargo.lock @@ -1883,6 +1883,7 @@ name = "shared-types" version = "0.1.0" dependencies = [ "serde", + "serde_json", ] [[package]] diff --git a/services/frontend/shared-types/Cargo.toml b/services/frontend/shared-types/Cargo.toml index 3f27442..8df0da8 100644 --- a/services/frontend/shared-types/Cargo.toml +++ b/services/frontend/shared-types/Cargo.toml @@ -5,3 +5,4 @@ edition = "2021" [dependencies] serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/services/frontend/shared-types/src/message.rs b/services/frontend/shared-types/src/message.rs index 1d9e696..3838993 100644 --- a/services/frontend/shared-types/src/message.rs +++ b/services/frontend/shared-types/src/message.rs @@ -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, } +// ── 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, D::Error> +where + T: DeserializeOwned, + D: Deserializer<'de>, +{ + // Intermediate Value to distinguish null / object / array / string + let v = Option::::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`. +pub(crate) fn deser_msg_meta<'de, D: Deserializer<'de>>( + d: D, +) -> Result, D::Error> { + from_json_string_or_value(d) +} + +/// Concrete wrapper for `Option>` fields +/// (ai_moderation_flags, ai_categories, etc.). +pub(crate) fn deser_str_vec<'de, D: Deserializer<'de>>( + d: D, +) -> Result>, 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, #[serde(skip_serializing_if = "Option::is_none")] pub ai_confidence: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + deserialize_with = "deser_str_vec", + skip_serializing_if = "Option::is_none" + )] pub ai_moderation_flags: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub ai_moderation_score: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ai_analysis: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + deserialize_with = "deser_str_vec", + skip_serializing_if = "Option::is_none" + )] pub ai_categories: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub ai_recommended_action: Option, @@ -135,7 +186,10 @@ pub struct MessageRecord { pub ai_error: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ai_analyzed_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + deserialize_with = "deser_msg_meta", + skip_serializing_if = "Option::is_none" + )] pub metadata: Option, }