The backend had never been run through rustfmt. Doing it in one mechanical pass (134 files) so no future functional diff is buried under formatting churn, then gating `cargo fmt --check` in checks.yml so it stays clean. Formatting only — no logic, SQL, or behaviour changed. Verified after the reformat: cargo test 56 passed, clippy --all-targets -D warnings clean, cargo fmt --check clean. This is the deferred cleanup noted when CI's Format step was first left out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
245 lines
9.3 KiB
Rust
245 lines
9.3 KiB
Rust
use axum::Json;
|
|
use axum::extract::{Path, Query, State};
|
|
use axum::http::StatusCode;
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use crate::auth::middleware::AuthUser;
|
|
use crate::error::AppError;
|
|
use crate::models::comment::{Comment, CommentDto};
|
|
use crate::models::hashtag::{self, Hashtag};
|
|
use crate::models::upload::Upload;
|
|
use crate::state::AppState;
|
|
|
|
#[derive(Serialize)]
|
|
pub struct LikeResponse {
|
|
/// The caller's like state *after* this toggle. The client sets `liked_by_me` from
|
|
/// this rather than blind-inverting local state — otherwise a second device (same
|
|
/// recovered user) drifts, since the `like-update` broadcast only carries `like_count`.
|
|
pub liked: bool,
|
|
/// Fresh like count, or `null` if the (best-effort) count query hiccuped. The client
|
|
/// keeps its current count when this is null rather than adopting a wrong number.
|
|
pub like_count: Option<i64>,
|
|
}
|
|
|
|
pub async fn toggle_like(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
Path(upload_id): Path<Uuid>,
|
|
) -> Result<Json<LikeResponse>, AppError> {
|
|
// Check if user is banned
|
|
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
|
if user.is_banned {
|
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
|
}
|
|
|
|
// Event-scope: the upload must belong to the caller's event (404 otherwise),
|
|
// matching the host handlers' find_by_id_and_event pattern.
|
|
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
|
|
|
// NOTE: liking is intentionally allowed while the event is locked. Locking
|
|
// ("Event schließen") freezes *new uploads* only — likes, comments and
|
|
// browsing stay open (USER_JOURNEYS §9.3, FEATURES capability matrix).
|
|
|
|
// Try to insert; if conflict, delete (toggle). `liked` = the caller's state afterwards.
|
|
let result = sqlx::query(
|
|
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
|
|
ON CONFLICT (upload_id, user_id) DO NOTHING",
|
|
)
|
|
.bind(upload_id)
|
|
.bind(auth.user_id)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
|
|
let liked = result.rows_affected() > 0;
|
|
if !liked {
|
|
// Already liked — remove
|
|
sqlx::query("DELETE FROM \"like\" WHERE upload_id = $1 AND user_id = $2")
|
|
.bind(upload_id)
|
|
.bind(auth.user_id)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
}
|
|
|
|
// Fresh count so feed clients can patch the single card in place instead of
|
|
// refetching page 1 (mirrors v_feed.like_count = COUNT(DISTINCT user_id)). The like
|
|
// itself is already committed, so a failed count must not fail the request — but we
|
|
// also must NOT broadcast/return a bogus 0 (that would push like_count: 0 to every
|
|
// client until the next event). On error we skip the broadcast and return null.
|
|
let like_count = sqlx::query_scalar::<_, i64>(
|
|
"SELECT COUNT(DISTINCT user_id) FROM \"like\" WHERE upload_id = $1",
|
|
)
|
|
.bind(upload_id)
|
|
.fetch_one(&state.pool)
|
|
.await
|
|
.ok();
|
|
|
|
if let Some(count) = like_count {
|
|
// Broadcast the new count so other clients patch their card. Only `like_count` is
|
|
// shared — each client's own `liked_by_me` only changes via its own toggle (which
|
|
// now reads it straight from this response).
|
|
let _ = state.sse_tx.send(crate::state::SseEvent {
|
|
event_type: "like-update".to_string(),
|
|
data: serde_json::json!({ "upload_id": upload_id, "like_count": count }).to_string(),
|
|
});
|
|
}
|
|
|
|
Ok(Json(LikeResponse { liked, like_count }))
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
pub struct ListCommentsQuery {
|
|
/// RFC3339 timestamp — return only comments older than this. Pass the
|
|
/// `created_at` of the oldest currently-loaded comment to fetch the next
|
|
/// older page.
|
|
pub before: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
const COMMENT_PAGE_SIZE: i64 = 50;
|
|
|
|
pub async fn list_comments(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
Path(upload_id): Path<Uuid>,
|
|
Query(q): Query<ListCommentsQuery>,
|
|
) -> Result<Json<Vec<CommentDto>>, AppError> {
|
|
// Event-scope: only list comments for an upload in the caller's event.
|
|
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
|
|
|
let comments =
|
|
Comment::list_for_upload(&state.pool, upload_id, q.before, COMMENT_PAGE_SIZE).await?;
|
|
Ok(Json(comments))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct AddCommentRequest {
|
|
pub body: String,
|
|
}
|
|
|
|
pub async fn add_comment(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
Path(upload_id): Path<Uuid>,
|
|
Json(body): Json<AddCommentRequest>,
|
|
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
|
|
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
|
if user.is_banned {
|
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
|
}
|
|
|
|
// Event-scope: only comment on an upload that belongs to the caller's event.
|
|
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
|
|
|
// NOTE: commenting is intentionally allowed while the event is locked. Locking
|
|
// freezes *new uploads* only — likes, comments and browsing stay open
|
|
// (USER_JOURNEYS §9.3, FEATURES capability matrix).
|
|
|
|
let text = body.body.trim();
|
|
let text_chars = text.chars().count();
|
|
if text_chars == 0 || text_chars > 500 {
|
|
return Err(AppError::BadRequest(
|
|
"Kommentar muss zwischen 1 und 500 Zeichen lang sein.".into(),
|
|
));
|
|
}
|
|
|
|
// Insert the comment and link its hashtags atomically, so a crash mid-loop
|
|
// can't leave a committed comment with only some of its tags indexed.
|
|
let tags = hashtag::extract_hashtags(text);
|
|
let mut tx = state.pool.begin().await?;
|
|
let comment = Comment::create(&mut *tx, upload_id, auth.user_id, text).await?;
|
|
for tag in &tags {
|
|
let h = Hashtag::upsert(&mut *tx, auth.event_id, tag).await?;
|
|
sqlx::query(
|
|
"INSERT INTO comment_hashtag (comment_id, hashtag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
|
)
|
|
.bind(comment.id)
|
|
.bind(h.id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
tx.commit().await?;
|
|
|
|
// Fresh count so feed clients can patch the single card in place instead of
|
|
// refetching page 1 (mirrors v_feed.comment_count = COUNT(DISTINCT c.id); COUNT(*)
|
|
// over the same deleted_at filter is identical since comment.id is the PK). The
|
|
// count + broadcast are a UI optimisation — the comment is already committed, so a
|
|
// failure here must not fail the request. Swallow the error and skip the broadcast.
|
|
if let Ok(comment_count) = sqlx::query_scalar::<_, i64>(
|
|
"SELECT COUNT(*) FROM comment WHERE upload_id = $1 AND deleted_at IS NULL",
|
|
)
|
|
.bind(upload_id)
|
|
.fetch_one(&state.pool)
|
|
.await
|
|
{
|
|
let _ = state.sse_tx.send(crate::state::SseEvent {
|
|
event_type: "new-comment".to_string(),
|
|
data: serde_json::json!({ "upload_id": upload_id, "comment_count": comment_count })
|
|
.to_string(),
|
|
});
|
|
}
|
|
|
|
let dto = CommentDto {
|
|
id: comment.id,
|
|
upload_id,
|
|
user_id: auth.user_id,
|
|
uploader_name: user.display_name,
|
|
body: comment.body,
|
|
created_at: comment.created_at,
|
|
};
|
|
|
|
Ok((StatusCode::CREATED, Json(dto)))
|
|
}
|
|
|
|
pub async fn delete_comment(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
Path(comment_id): Path<Uuid>,
|
|
) -> Result<StatusCode, AppError> {
|
|
// Banned users keep read access but cannot mutate (USER_JOURNEYS §10).
|
|
if auth.is_banned {
|
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
|
}
|
|
let comment = Comment::find_by_id(&state.pool, comment_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Kommentar nicht gefunden.".into()))?;
|
|
|
|
if comment.user_id != auth.user_id {
|
|
return Err(AppError::Forbidden("Nur eigene Kommentare löschen.".into()));
|
|
}
|
|
|
|
// Event-scope: soft_delete_in_event only matches comments whose upload is in
|
|
// the caller's event, so a cross-event comment_id resolves to a 404 here.
|
|
let mut tx = state.pool.begin().await?;
|
|
let deleted = Comment::soft_delete_in_event(&mut tx, comment_id, auth.event_id).await?;
|
|
if !deleted {
|
|
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
|
}
|
|
// Comments live only in the HTML viewer, so the ZIP is carried forward, not rebuilt.
|
|
let regen = crate::services::export::invalidate_and_arm(
|
|
&mut tx,
|
|
&state.config.event_slug,
|
|
crate::services::export::Affects::ViewerOnly,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
if let Some(r) = regen {
|
|
crate::handlers::host::start_regen(&state, r);
|
|
}
|
|
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
|
"comment-deleted",
|
|
serde_json::json!({ "comment_id": comment_id, "upload_id": comment.upload_id }).to_string(),
|
|
));
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|