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, pub limit: Option, pub hashtag: Option, } #[derive(Serialize)] pub struct FeedUpload { pub id: Uuid, pub user_id: Uuid, pub uploader_name: String, pub preview_url: Option, pub thumbnail_url: Option, pub mime_type: String, pub caption: Option, pub like_count: i64, pub comment_count: i64, pub liked_by_me: bool, pub created_at: DateTime, } #[derive(Serialize)] pub struct FeedResponse { pub uploads: Vec, pub next_cursor: Option, } #[derive(sqlx::FromRow)] struct FeedRow { id: Uuid, user_id: Uuid, uploader_name: String, preview_path: Option, thumbnail_path: Option, mime_type: String, caption: Option, like_count: i64, comment_count: i64, created_at: DateTime, } pub async fn feed( State(state): State, auth: AuthUser, Query(q): Query, ) -> Result, 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 = 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 = 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, } #[derive(Serialize)] pub struct DeltaResponse { pub uploads: Vec, pub deleted_ids: Vec, } pub async fn feed_delta( State(state): State, auth: AuthUser, Query(q): Query, ) -> Result, 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 = 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, auth: AuthUser, ) -> Result>, 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> { let row: Option<(DateTime,)> = 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 { 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() }