1use axum::Json;
7use axum::extract::{Path, Query, State};
8use axum::response::{IntoResponse, Response};
9use serde::Deserialize;
10use serde_json::{Value, json};
11
12use crate::app::{Ctx, Who};
13use crate::auth::Identity;
14use crate::error::{ApiError, ApiResult};
15use crate::http::ok;
16use crate::model;
17
18fn reviewer(who: &Who) -> ApiResult<&Identity> {
22 let id = &who.0;
23 if id.admin || id.manager {
24 Ok(id)
25 } else if id.pending {
26 Err(ApiError::forbidden("fair-play review needs your passkey check first"))
27 } else {
28 Err(ApiError::forbidden("fair-play review is for managers and admins"))
29 }
30}
31
32const REPORT_COLS: &str =
33 "r.game_id, r.side, a.username, r.score, r.flagged, r.moves, r.accuracy, r.acpl, r.top1, r.top3,
34 r.time_cv, r.time_corr, r.reasons, r.engine, r.depth, (extract(epoch from r.created_at) * 1000)::bigint,
35 r.app_disagree, r.app_picked, r.app_picked_reference, r.declared_assist";
36
37fn report(r: &tokio_postgres::Row) -> Value {
38 json!({
39 "gameId": r.get::<_, String>(0),
40 "side": r.get::<_, String>(1),
41 "user": r.get::<_, String>(2),
42 "score": r.get::<_, f32>(3),
43 "flagged": r.get::<_, bool>(4),
44 "moves": r.get::<_, i32>(5),
45 "accuracy": r.get::<_, f32>(6),
46 "acpl": r.get::<_, f32>(7),
47 "top1": r.get::<_, f32>(8),
48 "top3": r.get::<_, f32>(9),
49 "timeCv": r.get::<_, Option<f32>>(10),
50 "timeCorrelation": r.get::<_, Option<f32>>(11),
51 "reasons": r.get::<_, Vec<String>>(12),
52 "engine": r.get::<_, String>(13),
53 "depth": r.get::<_, i32>(14),
54 "createdAt": r.get::<_, i64>(15),
55 "appFingerprint": {
58 "disagreements": r.get::<_, Option<i32>>(16),
59 "pickedApp": r.get::<_, Option<i32>>(17),
60 "pickedStockfish": r.get::<_, Option<i32>>(18),
61 },
62 "declaredAssist": r.get::<_, bool>(19),
64 })
65}
66
67#[derive(Deserialize)]
68pub struct QueueQuery {
69 limit: Option<i64>,
70 all: Option<bool>,
72}
73
74pub async fn queue(State(ctx): State<Ctx>, who: Who, Query(q): Query<QueueQuery>) -> ApiResult<Response> {
76 reviewer(&who)?;
77 let c = ctx.db.conn().await?;
78 let rows = c
79 .query(
80 &ctx.db.sql(&format!(
81 "SELECT {REPORT_COLS} FROM chess.fairplay_reports r JOIN chess.accounts a ON a.player_id = r.player_id
82 WHERE r.flagged AND ($2 OR NOT EXISTS (
83 SELECT 1 FROM chess.fairplay_reviews v WHERE v.player_id = r.player_id AND v.game_id = r.game_id))
84 ORDER BY r.created_at DESC LIMIT $1"
85 )),
86 &[&q.limit.unwrap_or(50).clamp(1, 500), &q.all.unwrap_or(false)],
87 )
88 .await?;
89 Ok(Json(rows.iter().map(report).collect::<Vec<_>>()).into_response())
90}
91
92pub async fn game(State(ctx): State<Ctx>, who: Who, Path(id): Path<String>) -> ApiResult<Response> {
94 reviewer(&who)?;
95 let c = ctx.db.conn().await?;
96 let rows = c
97 .query(
98 &ctx.db.sql(&format!(
99 "SELECT {REPORT_COLS}, r.evals FROM chess.fairplay_reports r JOIN chess.accounts a ON a.player_id = r.player_id
100 WHERE r.game_id = $1 ORDER BY r.side DESC"
101 )),
102 &[&id],
103 )
104 .await?;
105 let analysis: Option<String> = c
106 .query_opt(&ctx.db.sql("SELECT analysis FROM chess.games WHERE id = $1"), &[&id])
107 .await?
108 .map(|r| r.get(0));
109 let Some(analysis) = analysis else { return Err(ApiError::not_found("game")) };
110 let reports: Vec<Value> = rows
111 .iter()
112 .map(|r| {
113 let mut v = report(r);
114 v["evals"] = r.get::<_, Value>(20);
115 v
116 })
117 .collect();
118 Ok(Json(json!({"gameId": id, "analysis": analysis, "reports": reports})).into_response())
119}
120
121pub async fn player(State(ctx): State<Ctx>, who: Who, Path(username): Path<String>) -> ApiResult<Response> {
123 reviewer(&who)?;
124 let c = ctx.db.conn().await?;
125 let a =
126 model::account_by_name(&ctx.db, &*c, &username).await?.ok_or_else(|| ApiError::not_found("user"))?;
127 let s = c
128 .query_one(
129 &ctx.db.sql(
130 "SELECT count(*), count(*) FILTER (WHERE flagged), coalesce(avg(score), 0)::real,
131 coalesce(avg(score) FILTER (WHERE created_at > now() - interval '30 days'), 0)::real
132 FROM chess.fairplay_reports WHERE player_id = $1",
133 ),
134 &[&a.player_id],
135 )
136 .await?;
137 let recent = c
138 .query(
139 &ctx.db.sql(&format!(
140 "SELECT {REPORT_COLS} FROM chess.fairplay_reports r JOIN chess.accounts a ON a.player_id = r.player_id
141 WHERE r.player_id = $1 ORDER BY r.created_at DESC LIMIT 20"
142 )),
143 &[&a.player_id],
144 )
145 .await?;
146 let reviews: Vec<Value> = c
147 .query(
148 &ctx.db.sql(
149 "SELECT v.game_id, v.verdict, v.note, p.name, (extract(epoch from v.at) * 1000)::bigint
150 FROM chess.fairplay_reviews v LEFT JOIN chess.players p ON p.id = v.reviewer_id
151 WHERE v.player_id = $1 ORDER BY v.at DESC LIMIT 50",
152 ),
153 &[&a.player_id],
154 )
155 .await?
156 .iter()
157 .map(|r| {
158 json!({"gameId": r.get::<_, Option<String>>(0), "verdict": r.get::<_, String>(1),
159 "note": r.get::<_, Option<String>>(2), "reviewer": r.get::<_, Option<String>>(3),
160 "at": r.get::<_, i64>(4)})
161 })
162 .collect();
163 Ok(Json(json!({
164 "user": a.username,
165 "bot": a.bot,
166 "marked": a.marked,
167 "screened": s.get::<_, i64>(0),
168 "flagged": s.get::<_, i64>(1),
169 "meanScore": s.get::<_, f32>(2),
170 "meanScore30d": s.get::<_, f32>(3),
171 "recent": recent.iter().map(report).collect::<Vec<_>>(),
172 "reviews": reviews,
173 }))
174 .into_response())
175}
176
177#[derive(Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct Review {
180 username: String,
181 game_id: Option<String>,
182 verdict: String,
184 note: Option<String>,
185}
186
187pub async fn review(State(ctx): State<Ctx>, who: Who, Json(r): Json<Review>) -> ApiResult<Response> {
191 let me = reviewer(&who)?;
192 if !matches!(r.verdict.as_str(), "clean" | "engine" | "unclear") {
193 return Err(ApiError::bad("verdict is clean, engine or unclear"));
194 }
195 let mut c = ctx.db.conn().await?;
196 let tx = c.transaction().await?;
197 let a = model::account_by_name(&ctx.db, &tx, &r.username)
198 .await?
199 .ok_or_else(|| ApiError::not_found("user"))?;
200 tx.execute(
201 &ctx.db.sql(
202 "INSERT INTO chess.fairplay_reviews (player_id, game_id, verdict, note, reviewer_id) VALUES ($1, $2, $3, $4, $5)",
203 ),
204 &[&a.player_id, &r.game_id, &r.verdict, &r.note, &me.player],
205 )
206 .await?;
207 match r.verdict.as_str() {
208 "engine" => {
209 tx.execute(
210 &ctx.db
211 .sql("UPDATE chess.accounts SET marked_at = now(), marked_by = $2 WHERE player_id = $1"),
212 &[&a.player_id, &me.player],
213 )
214 .await?;
215 }
216 "clean" => {
217 tx.execute(
218 &ctx.db
219 .sql("UPDATE chess.accounts SET marked_at = NULL, marked_by = NULL WHERE player_id = $1"),
220 &[&a.player_id],
221 )
222 .await?;
223 }
224 _ => {}
225 }
226 tx.commit().await?;
227 Ok(ok())
228}