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