Files
EventSnap/backend/src/handlers/feed.rs
fabi 964598e41d feat: implement gallery feed with social features and SSE
- Cursor-based feed endpoint using v_feed view with hashtag filtering
- Like toggle (INSERT ON CONFLICT), comments CRUD
- Feed delta endpoint for SSE-driven incremental updates
- SSE client with Page Visibility API (pause/reconnect)
- Responsive photo/video grid with infinite scroll
- Hashtag filter chips, lightbox modal with comments
- Media file serving via tower-http ServeDir

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:17:06 +02:00

259 lines
7.3 KiB
Rust

use axum::extract::{Query, State};
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct FeedQuery {
pub cursor: Option<Uuid>,
pub limit: Option<i64>,
pub hashtag: Option<String>,
}
#[derive(Serialize)]
pub struct FeedUpload {
pub id: Uuid,
pub user_id: Uuid,
pub uploader_name: String,
pub preview_url: Option<String>,
pub thumbnail_url: Option<String>,
pub mime_type: String,
pub caption: Option<String>,
pub like_count: i64,
pub comment_count: i64,
pub liked_by_me: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct FeedResponse {
pub uploads: Vec<FeedUpload>,
pub next_cursor: Option<Uuid>,
}
#[derive(sqlx::FromRow)]
struct FeedRow {
id: Uuid,
user_id: Uuid,
uploader_name: String,
preview_path: Option<String>,
thumbnail_path: Option<String>,
mime_type: String,
caption: Option<String>,
like_count: i64,
comment_count: i64,
created_at: DateTime<Utc>,
}
pub async fn feed(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<FeedQuery>,
) -> Result<Json<FeedResponse>, AppError> {
let limit = q.limit.unwrap_or(20).min(100);
let rows = if let Some(hashtag) = &q.hashtag {
let tag = hashtag.trim().trim_start_matches('#').to_lowercase();
sqlx::query_as::<_, FeedRow>(
"SELECT v.id, v.user_id, v.uploader_name, v.preview_path, v.thumbnail_path,
v.mime_type, v.caption, v.like_count, v.comment_count, v.created_at
FROM v_feed v
JOIN upload_hashtag uh ON uh.upload_id = v.id
JOIN hashtag h ON h.id = uh.hashtag_id AND h.tag = $1
WHERE v.event_id = $2
AND ($3::timestamptz IS NULL OR v.created_at < $3)
ORDER BY v.created_at DESC
LIMIT $4",
)
.bind(&tag)
.bind(auth.event_id)
.bind(
if let Some(cursor) = q.cursor {
get_cursor_time(&state.pool, cursor).await
} else {
None
},
)
.bind(limit + 1)
.fetch_all(&state.pool)
.await?
} else {
sqlx::query_as::<_, FeedRow>(
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
mime_type, caption, like_count, comment_count, created_at
FROM v_feed
WHERE event_id = $1
AND ($2::timestamptz IS NULL OR created_at < $2)
ORDER BY created_at DESC
LIMIT $3",
)
.bind(auth.event_id)
.bind(
if let Some(cursor) = q.cursor {
get_cursor_time(&state.pool, cursor).await
} else {
None
},
)
.bind(limit + 1)
.fetch_all(&state.pool)
.await?
};
let has_more = rows.len() as i64 > limit;
let rows: Vec<FeedRow> = rows.into_iter().take(limit as usize).collect();
let next_cursor = if has_more { rows.last().map(|r| r.id) } else { None };
// Batch check which uploads the current user has liked
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
let uploads = rows
.into_iter()
.map(|r| {
let preview_url = r.preview_path.map(|p| format!("/media/{p}"));
let thumbnail_url = r.thumbnail_path.map(|p| format!("/media/{p}"));
FeedUpload {
liked_by_me: liked_set.contains(&r.id),
id: r.id,
user_id: r.user_id,
uploader_name: r.uploader_name,
preview_url,
thumbnail_url,
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
comment_count: r.comment_count,
created_at: r.created_at,
}
})
.collect();
Ok(Json(FeedResponse {
uploads,
next_cursor,
}))
}
#[derive(Deserialize)]
pub struct DeltaQuery {
pub since: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct DeltaResponse {
pub uploads: Vec<FeedUpload>,
pub deleted_ids: Vec<Uuid>,
}
pub async fn feed_delta(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<DeltaQuery>,
) -> Result<Json<DeltaResponse>, AppError> {
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
mime_type, caption, like_count, comment_count, created_at
FROM v_feed
WHERE event_id = $1 AND created_at > $2
ORDER BY created_at DESC",
)
.bind(auth.event_id)
.bind(q.since)
.fetch_all(&state.pool)
.await?;
let deleted_ids: Vec<(Uuid,)> = sqlx::query_as(
"SELECT id FROM upload
WHERE event_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2",
)
.bind(auth.event_id)
.bind(q.since)
.fetch_all(&state.pool)
.await?;
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
let uploads = rows
.into_iter()
.map(|r| FeedUpload {
liked_by_me: liked_set.contains(&r.id),
id: r.id,
user_id: r.user_id,
uploader_name: r.uploader_name,
preview_url: r.preview_path.map(|p| format!("/media/{p}")),
thumbnail_url: r.thumbnail_path.map(|p| format!("/media/{p}")),
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
comment_count: r.comment_count,
created_at: r.created_at,
})
.collect();
Ok(Json(DeltaResponse {
uploads,
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
}))
}
#[derive(Serialize)]
pub struct HashtagCount {
pub tag: String,
pub count: i64,
}
pub async fn hashtags(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Vec<HashtagCount>>, AppError> {
let rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT tag, upload_count FROM v_hashtag_counts WHERE event_id = $1",
)
.bind(auth.event_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(
rows.into_iter()
.map(|(tag, count)| HashtagCount { tag, count })
.collect(),
))
}
async fn get_cursor_time(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option<DateTime<Utc>> {
let row: Option<(DateTime<Utc>,)> =
sqlx::query_as("SELECT created_at FROM upload WHERE id = $1")
.bind(cursor_id)
.fetch_optional(pool)
.await
.ok()?;
row.map(|r| r.0)
}
async fn get_liked_set(
pool: &sqlx::PgPool,
user_id: Uuid,
upload_ids: &[Uuid],
) -> std::collections::HashSet<Uuid> {
if upload_ids.is_empty() {
return std::collections::HashSet::new();
}
let rows: Vec<(Uuid,)> = sqlx::query_as(
"SELECT upload_id FROM \"like\" WHERE user_id = $1 AND upload_id = ANY($2)",
)
.bind(user_id)
.bind(upload_ids)
.fetch_all(pool)
.await
.unwrap_or_default();
rows.into_iter().map(|r| r.0).collect()
}