Web dashboard step 2: full issue pages, article chapters, POST /rate

Signed-in issue page with the Brief, downloads, index and colophon; article,
World Briefing and Behind the paper pages under the login guard; the rating
widget with its fetch enhancement; the admin-only POST /rate writing
dashboard-attributed rating events; the HMAC confirmation page links to the
site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
thalladaandClaude Fable 5.1 committed 2026-09-03 04:56:29 +00:00
1 parent 69cd736455
commit 4995a8bd5d
13 files changed
+1383 -31

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
use std::collections::HashMap;
use axum::Form;
use axum::extract::{Extension, FromRequest, Json, Request, State};
use axum::http::{HeaderMap, header};
use axum::response::{IntoResponse, Response};
use axum_login::tower_sessions::Session;
use jiff::civil::Date;
use serde::{Deserialize, Serialize};
use sqlx::Row;
use crate::server::AppState;
use crate::types::{ArticleId, RatingEvent, Vote};
use crate::web::session::{self, AuthSession};
use crate::web::{Flash, WebError};
#[derive(Debug, Clone)]
pub struct RatingWidget {
pub article_id: ArticleId,
pub issue_date: String,
pub next: String,
pub current: String,
pub show_note: bool,
}
impl RatingWidget {
pub fn for_issue(
article_id: ArticleId,
issue_date: Date,
next: impl Into<String>,
current: Option<&str>,
) -> Self {
Self {
article_id,
issue_date: issue_date.to_string(),
next: next.into(),
current: web_label(current).to_string(),
show_note: false,
}
}
}
fn web_label(label: Option<&str>) -> &str {
match label {
Some("not_for_me" | "down") => "down",
Some("loved") => "loved",
Some("good") => "good",
Some("cleared") => "cleared",
_ => "",
}
}
pub async fn current_for_issue(
state: &AppState,
date: Date,
) -> Result<HashMap<ArticleId, String>, WebError> {
let rows = sqlx::query(
"WITH ranked AS (
SELECT re.article_id, re.label,
ROW_NUMBER() OVER (
PARTITION BY re.article_id
ORDER BY re.event_at DESC, re.id DESC
) AS event_rank
FROM rating_events re
JOIN issue_articles ia ON ia.article_id = re.article_id
WHERE re.kind = 'explicit' AND ia.issue_date = ?
)
SELECT article_id, label FROM ranked WHERE event_rank = 1",
)
.bind(date.to_string())
.fetch_all(state.db.pool())
.await
.map_err(crate::db::DbError::from)?;
Ok(rows
.into_iter()
.map(|row| (row.get("article_id"), row.get("label")))
.collect())
}
#[derive(Debug, Deserialize)]
pub struct RatingInput {
article_id: ArticleId,
#[serde(default)]
issue_date: Option<String>,
label: String,
#[serde(default)]
note: Option<String>,
#[serde(default)]
next: Option<String>,
}
#[derive(Debug, Serialize)]
struct RatingResponse {
article_id: ArticleId,
label: String,
event_id: i64,
}
pub async fn post(
State(state): State<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
headers: HeaderMap,
request: Request,
) -> Result<Response, WebError> {
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let input = if content_type.starts_with("application/json") {
Json::<RatingInput>::from_request(request, &state)
.await
.map(|Json(input)| input)
.map_err(|_| WebError::BadRequest("invalid rating JSON".into()))?
} else {
Form::<RatingInput>::from_request(request, &state)
.await
.map(|Form(input)| input)
.map_err(|_| WebError::BadRequest("invalid rating form".into()))?
};
let viewer = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
next: "/rate".into(),
})?;
if state.db.get_article(input.article_id).await?.is_none() {
return Err(WebError::BadRequest(format!(
"article {} does not exist",
input.article_id
)));
}
let issue_date = match input
.issue_date
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(raw) => Some(
raw.parse::<Date>()
.map_err(|_| WebError::BadRequest("invalid issue date".into()))?,
),
None => {
state
.db
.latest_issue_date_for_article(input.article_id)
.await?
}
};
let config = state.config();
let (event_label, value, response_label, flash_label) = match input.label.as_str() {
"loved" => (
"loved",
Vote::Loved.value(&config.curation.feedback),
"loved",
"Loved it",
),
"good" => (
"good",
Vote::Good.value(&config.curation.feedback),
"good",
"Good",
),
"down" => (
"not_for_me",
Vote::NotForMe.value(&config.curation.feedback),
"down",
"Not for me",
),
"cleared" => ("cleared", 0.0, "cleared", "Cleared"),
_ => return Err(WebError::BadRequest("invalid rating label".into())),
};
let note = input
.note
.map(|note| note.trim().to_string())
.filter(|note| !note.is_empty());
let event_id = state
.db
.append_rating_event(&RatingEvent {
id: 0,
user_id: Some(viewer.id),
article_id: input.article_id,
issue_date,
kind: "explicit".into(),
source: "dashboard".into(),
label: event_label.into(),
value,
note,
event_at: jiff::Timestamp::now(),
})
.await?;
let wants_json = headers
.get(header::ACCEPT)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("application/json"));
if wants_json {
return Ok(Json(RatingResponse {
article_id: input.article_id,
label: response_label.into(),
event_id,
})
.into_response());
}
session
.insert(
"flash",
Flash {
kind: "success".into(),
text: format!("Rated: {flash_label}"),
},
)
.await
.map_err(|error| WebError::Internal(error.into()))?;
let destination = session::valid_next(input.next.as_deref()).to_string();
Ok(axum::response::Redirect::to(&destination).into_response())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn database_and_widget_labels_are_mapped_explicitly() {
assert_eq!(web_label(Some("not_for_me")), "down");
assert_eq!(web_label(Some("cleared")), "cleared");
assert_eq!(web_label(None), "");
}
}