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}