use serde::Serialize;
use crate::report::Report;
#[derive(Debug, Serialize)]
pub(super) struct ReportListEntry {
pub id: i64,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "reasonType")]
pub reason_type: String,
pub subject: SubjectView,
#[serde(rename = "reportedBy")]
pub reported_by: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "resolvedAt")]
pub resolved_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "resolvedBy")]
pub resolved_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "resolutionLabel")]
pub resolution_label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "resolutionReason")]
pub resolution_reason: Option<String>,
}
#[derive(Debug, Serialize)]
pub(super) struct ReportDetail {
pub id: i64,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "reasonType")]
pub reason_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub subject: SubjectView,
#[serde(rename = "reportedBy")]
pub reported_by: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "resolvedAt")]
pub resolved_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "resolvedBy")]
pub resolved_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "resolutionLabel")]
pub resolution_label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "resolutionReason")]
pub resolution_reason: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(tag = "$type")]
pub(super) enum SubjectView {
#[serde(rename = "com.atproto.admin.defs#repoRef")]
Repo { did: String },
#[serde(rename = "com.atproto.repo.strongRef")]
Strong { uri: String, cid: String },
}
fn build_subject(report: &Report) -> SubjectView {
match report.subject_type.as_str() {
"record" => SubjectView::Strong {
uri: report.subject_uri.clone().unwrap_or_default(),
cid: report.subject_cid.clone().unwrap_or_default(),
},
_ => SubjectView::Repo {
did: report.subject_did.clone(),
},
}
}
pub(super) fn project_for_list(report: Report) -> ReportListEntry {
let subject = build_subject(&report);
ReportListEntry {
id: report.id,
created_at: report.created_at,
reason_type: report.reason_type,
subject,
reported_by: report.reported_by,
status: report.status,
resolved_at: report.resolved_at,
resolved_by: report.resolved_by,
resolution_label: report.resolution_label,
resolution_reason: report.resolution_reason,
}
}
pub(super) fn project_for_fetch(report: Report) -> ReportDetail {
let subject = build_subject(&report);
ReportDetail {
id: report.id,
created_at: report.created_at,
reason_type: report.reason_type,
reason: report.reason,
subject,
reported_by: report.reported_by,
status: report.status,
resolved_at: report.resolved_at,
resolved_by: report.resolved_by,
resolution_label: report.resolution_label,
resolution_reason: report.resolution_reason,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(reason: Option<&str>, subject_type: &str) -> Report {
Report {
id: 1,
created_at: "2026-04-23T00:00:00.000Z".into(),
reported_by: "did:plc:reporter".into(),
reason_type: "com.atproto.moderation.defs#reasonSpam".into(),
reason: reason.map(str::to_string),
subject_type: subject_type.into(),
subject_did: "did:plc:target".into(),
subject_uri: Some("at://did:plc:target/col/r".into()),
subject_cid: Some("bafy".into()),
status: "pending".into(),
resolved_at: None,
resolved_by: None,
resolution_label: None,
resolution_reason: None,
}
}
#[test]
fn list_projection_drops_reason_even_when_stored() {
let r = sample(Some("sensitive report body"), "account");
let entry = project_for_list(r);
let json = serde_json::to_value(&entry).unwrap();
assert!(
json.get("reason").is_none(),
"list projection must never serialize a reason field: {json}"
);
}
#[test]
fn fetch_projection_includes_reason() {
let r = sample(Some("sensitive report body"), "record");
let detail = project_for_fetch(r);
let json = serde_json::to_value(&detail).unwrap();
assert_eq!(json["reason"], "sensitive report body");
}
#[test]
fn record_subject_type_serializes_as_strong_ref() {
let r = sample(None, "record");
let detail = project_for_fetch(r);
let json = serde_json::to_value(&detail).unwrap();
assert_eq!(json["subject"]["$type"], "com.atproto.repo.strongRef");
assert_eq!(json["subject"]["uri"], "at://did:plc:target/col/r");
assert_eq!(json["subject"]["cid"], "bafy");
}
#[test]
fn account_subject_type_serializes_as_repo_ref() {
let r = sample(None, "account");
let detail = project_for_fetch(r);
let json = serde_json::to_value(&detail).unwrap();
assert_eq!(json["subject"]["$type"], "com.atproto.admin.defs#repoRef");
assert_eq!(json["subject"]["did"], "did:plc:target");
}
}