Skip to main content

cairn_mod/
report.rs

1//! Report domain type — flat projection of the `reports` table
2//! (§F11 intake schema in migrations/0001_init.sql).
3//!
4//! Kept flat (string columns for `subject_type` rather than enums)
5//! so callers can map directly from `sqlx` rows without a conversion
6//! step, and so the writer's `ResolvedReport` response and the admin
7//! `listReports` / `getReport` handlers share one type instead of
8//! reserializing across three layers.
9//!
10//! Status is the one column that *is* typed: [`ReportStatus`]
11//! mirrors the `status IN ('pending', 'resolved')` CHECK constraint
12//! at the type level (#27, parallel to [`crate::moderators::Role`]).
13//! sqlx [`Type`](sqlx::Type) / [`Decode`](sqlx::Decode) /
14//! [`Encode`](sqlx::Encode) impls let `query_as!(Report, ...)`
15//! consume the column directly — no separate row struct is needed.
16
17use std::str::FromStr;
18
19use serde::{Deserialize, Serialize};
20use sqlx::Sqlite;
21use sqlx::encode::IsNull;
22use sqlx::error::BoxDynError;
23
24/// Status values persisted in `reports.status` (§F11). The schema
25/// CHECK constrains the column to exactly these two strings, so any
26/// other value in a read means corrupt data, not an unknown status.
27///
28/// Wire shape mirrors the lexicon's
29/// `tools.cairn.admin.defs#reportView.status` `knownValues`
30/// (`"pending"` / `"resolved"`); serde uses `rename_all = "lowercase"`
31/// so on-the-wire bytes are identical to the pre-#27 string form.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum ReportStatus {
35    /// Open report awaiting moderator action.
36    Pending,
37    /// Closed report; `resolved_*` columns populated.
38    Resolved,
39}
40
41impl ReportStatus {
42    /// DB-side string representation. Must match the CHECK
43    /// constraint in migrations and the lexicon `knownValues`.
44    pub fn as_str(self) -> &'static str {
45        match self {
46            ReportStatus::Pending => "pending",
47            ReportStatus::Resolved => "resolved",
48        }
49    }
50
51    /// Parse the string stored in `reports.status` (or supplied via
52    /// the `listReports` `status=` query param). Returns `None`
53    /// if the value doesn't match the CHECK constraint.
54    pub fn from_db_str(s: &str) -> Option<ReportStatus> {
55        match s {
56            "pending" => Some(ReportStatus::Pending),
57            "resolved" => Some(ReportStatus::Resolved),
58            _ => None,
59        }
60    }
61}
62
63impl std::fmt::Display for ReportStatus {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.write_str(self.as_str())
66    }
67}
68
69impl FromStr for ReportStatus {
70    type Err = ();
71    fn from_str(s: &str) -> Result<Self, Self::Err> {
72        ReportStatus::from_db_str(s).ok_or(())
73    }
74}
75
76// sqlx integration: TEXT-backed enum. Manual impls (rather than
77// the `sqlx::Type` derive) keep the trait surface explicit and
78// avoid pulling macro internals into the public type.
79impl sqlx::Type<Sqlite> for ReportStatus {
80    fn type_info() -> <Sqlite as sqlx::Database>::TypeInfo {
81        <str as sqlx::Type<Sqlite>>::type_info()
82    }
83    fn compatible(ty: &<Sqlite as sqlx::Database>::TypeInfo) -> bool {
84        <str as sqlx::Type<Sqlite>>::compatible(ty)
85    }
86}
87
88impl<'r> sqlx::Decode<'r, Sqlite> for ReportStatus {
89    fn decode(value: <Sqlite as sqlx::Database>::ValueRef<'r>) -> Result<Self, BoxDynError> {
90        let s = <&str as sqlx::Decode<Sqlite>>::decode(value)?;
91        ReportStatus::from_db_str(s)
92            .ok_or_else(|| format!("reports.status value {s:?} violates CHECK constraint").into())
93    }
94}
95
96impl<'q> sqlx::Encode<'q, Sqlite> for ReportStatus {
97    fn encode_by_ref(
98        &self,
99        buf: &mut <Sqlite as sqlx::Database>::ArgumentBuffer<'q>,
100    ) -> Result<IsNull, BoxDynError> {
101        <&str as sqlx::Encode<Sqlite>>::encode_by_ref(&self.as_str(), buf)
102    }
103}
104
105/// A row from the `reports` table. Field names / types match the
106/// schema 1:1; see `lexicons/tools/cairn/admin/defs.json#reportView`
107/// for the wire projection that admin handlers build from this.
108#[derive(Debug, Clone, sqlx::FromRow)]
109pub struct Report {
110    /// Report row primary key. Stable across a deployment; used
111    /// as the opaque handle by `getReport` / `resolveReport`.
112    pub id: i64,
113    /// RFC-3339 Z with ms precision (§6.1 wire format).
114    pub created_at: String,
115    /// DID of the caller whose service-auth JWT verified at
116    /// `createReport` intake.
117    pub reported_by: String,
118    /// `com.atproto.moderation.defs#reason*` value — see §F11 for
119    /// the accepted set.
120    pub reason_type: String,
121    /// Report body. §F11: never returned by public endpoints,
122    /// never written to logs. Admin handlers enforce via
123    /// `project_for_list` vs `project_for_fetch` in
124    /// `src/server/admin/report_view.rs`.
125    pub reason: Option<String>,
126    /// One of `"account"` / `"record"` per the schema CHECK.
127    pub subject_type: String,
128    /// Subject identifier: for `"account"` this is the DID of the
129    /// account being reported; for `"record"` this is the DID
130    /// portion of the `at://` URI.
131    pub subject_did: String,
132    /// For record subjects, the full `at://` URI. `None` when
133    /// `subject_type == "account"`.
134    pub subject_uri: Option<String>,
135    /// For record subjects, the content-addressed ID pinning the
136    /// reported record version. `None` when `subject_type ==
137    /// "account"`.
138    pub subject_cid: Option<String>,
139    /// `pending` or `resolved`. Typed via [`ReportStatus`] (#27);
140    /// schema CHECK enforces the same set at the DB layer.
141    pub status: ReportStatus,
142    /// When `status == Resolved`, the RFC-3339 Z timestamp of
143    /// the resolution. `None` while pending.
144    pub resolved_at: Option<String>,
145    /// When resolved, the moderator DID that issued the
146    /// resolution. `None` while pending.
147    pub resolved_by: Option<String>,
148    /// If the resolution emitted a label, its value (the `val`
149    /// field on the emitted label). `None` if resolved without
150    /// labeling.
151    pub resolution_label: Option<String>,
152    /// Free-text resolution rationale (not the same as the
153    /// `reason` — this is the MODERATOR's rationale, recorded at
154    /// resolve time).
155    pub resolution_reason: Option<String>,
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn from_db_str_round_trips_known_values() {
164        for s in ["pending", "resolved"] {
165            let st = ReportStatus::from_db_str(s).expect("known value");
166            assert_eq!(st.as_str(), s);
167        }
168    }
169
170    #[test]
171    fn from_db_str_rejects_unknown() {
172        assert!(ReportStatus::from_db_str("Pending").is_none());
173        assert!(ReportStatus::from_db_str("").is_none());
174        assert!(ReportStatus::from_db_str("dismissed").is_none());
175    }
176
177    #[test]
178    fn serializes_as_lowercase_string() {
179        let v = serde_json::to_value(ReportStatus::Pending).unwrap();
180        assert_eq!(v, serde_json::Value::String("pending".into()));
181        let v = serde_json::to_value(ReportStatus::Resolved).unwrap();
182        assert_eq!(v, serde_json::Value::String("resolved".into()));
183    }
184
185    #[test]
186    fn deserializes_from_lexicon_known_values() {
187        let p: ReportStatus = serde_json::from_str("\"pending\"").unwrap();
188        assert_eq!(p, ReportStatus::Pending);
189        let r: ReportStatus = serde_json::from_str("\"resolved\"").unwrap();
190        assert_eq!(r, ReportStatus::Resolved);
191    }
192}