use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::enums::{
ModelRole, ModerationActionType, ModerationTargetType, ModerationTier,
};
use crate::ids::{
AgentId, AppealId, FlagId, ModerationActionId, ModerationNoteId,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ReversalStatus {
Unknown,
NotReversed,
Reversed {
at: DateTime<Utc>,
by_appeal: AppealId,
},
}
impl ReversalStatus {
pub fn known_standing(&self) -> bool {
matches!(self, ReversalStatus::NotReversed)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ModerationActionRecord {
pub id: ModerationActionId,
pub target_type: ModerationTargetType,
pub action_type: ModerationActionType,
pub tier: ModerationTier,
pub reason: String,
pub constitutional_ref: String,
pub created_at: DateTime<Utc>,
pub suspension_until: Option<DateTime<Utc>>,
pub reversal: ReversalStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NoteSource {
Tier2Review { flag: FlagId },
Appeal { appeal: AppealId },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ModerationNote {
pub id: ModerationNoteId,
pub subject_agent_id: AgentId,
pub author_role: ModelRole,
pub note: String,
pub citations: Vec<uuid::Uuid>,
pub source: NoteSource,
pub created_at: DateTime<Utc>,
pub superseded_by: Option<ModerationNoteId>,
}
impl ModerationNote {
pub fn is_superseded(&self) -> bool {
self.superseded_by.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_reversal_does_not_count_as_standing() {
assert!(!ReversalStatus::Unknown.known_standing());
assert!(ReversalStatus::NotReversed.known_standing());
assert!(
!ReversalStatus::Reversed {
at: Utc::now(),
by_appeal: AppealId::new(),
}
.known_standing()
);
}
#[test]
fn reversal_status_round_trips_tagged() {
let reversed = ReversalStatus::Reversed {
at: Utc::now(),
by_appeal: AppealId::new(),
};
let json = serde_json::to_value(&reversed).unwrap();
assert_eq!(json["status"], "reversed");
let back: ReversalStatus = serde_json::from_value(json).unwrap();
assert_eq!(back, reversed);
let unknown = serde_json::to_value(ReversalStatus::Unknown).unwrap();
assert_eq!(unknown["status"], "unknown");
}
#[test]
fn note_source_round_trips_tagged() {
let source = NoteSource::Tier2Review {
flag: FlagId::new(),
};
let json = serde_json::to_value(source).unwrap();
assert_eq!(json["kind"], "tier2_review");
let back: NoteSource = serde_json::from_value(json).unwrap();
assert_eq!(back, source);
}
#[cfg(feature = "schemars")]
#[test]
fn moderation_schemas_are_inlined() {
use schemars::JsonSchema;
for (name, schema) in [
("ReversalStatus", schemars::schema_for!(ReversalStatus)),
("NoteSource", schemars::schema_for!(NoteSource)),
("ModerationNote", schemars::schema_for!(ModerationNote)),
(
"ModerationActionRecord",
schemars::schema_for!(ModerationActionRecord),
),
] {
let rendered = serde_json::to_value(&schema).unwrap().to_string();
assert!(
!rendered.contains("$ref") && !rendered.contains("$defs"),
"{name}: schema carries $ref/$defs — a #[derive(JsonSchema)] \
on a nested enum silently reintroduces it: {rendered}"
);
}
assert!(<ReversalStatus as JsonSchema>::inline_schema());
assert!(<NoteSource as JsonSchema>::inline_schema());
}
#[test]
fn model_role_serializes_snake_case() {
assert_eq!(ModelRole::Tier2Reviewer.to_string(), "tier2_reviewer");
assert_eq!(ModelRole::AppealsJudge.to_string(), "appeals_judge");
assert_eq!(
"chambers".parse::<ModelRole>().unwrap(),
ModelRole::Chambers
);
}
}