diff --git a/src/handlers/raid_quest.rs b/src/handlers/raid_quest.rs
index bc27a64..e6f1b83 100644
--- a/src/handlers/raid_quest.rs
+++ b/src/handlers/raid_quest.rs
@@ -186,18 +186,13 @@ pub async fn handle_create_raid_submission(
Extension(user): Extension
,
extract::Json(payload): Json,
) -> Result<(StatusCode, Json>), AppError> {
- let (current_active_raid, user_x) = get_active_raid_and_x_association(&state, &user).await?;
+ let (current_active_raid, _user_x) = get_active_raid_and_x_association(&state, &user).await?;
- let Some((reply_username, reply_id)) = parse_x_status_url(&payload.tweet_reply_link) else {
+ let Some((_reply_username, reply_id)) = parse_x_status_url(&payload.tweet_reply_link) else {
return Err(AppError::Handler(HandlerError::InvalidBody(
"Couldn't parse tweet reply link".to_string(),
)));
};
- if user_x.username.to_lowercase() != reply_username.to_lowercase() {
- return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized(
- "Only tweet reply author is eligible to submit".to_string(),
- ))));
- }
let new_raid_submission = CreateRaidSubmission {
id: reply_id,
diff --git a/src/models/raid_submission.rs b/src/models/raid_submission.rs
index b0c6d88..5d623da 100644
--- a/src/models/raid_submission.rs
+++ b/src/models/raid_submission.rs
@@ -91,3 +91,9 @@ pub struct RaiderSubmissions {
pub current_raid: RaidQuest,
pub submissions: Vec,
}
+
+#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
+pub struct ValidRaidSubmissionWithRaiderUsername {
+ pub raid_submission_id: String,
+ pub raider_username: String,
+}
diff --git a/src/repositories/raid_submission.rs b/src/repositories/raid_submission.rs
index 6444ca7..1d4c533 100644
--- a/src/repositories/raid_submission.rs
+++ b/src/repositories/raid_submission.rs
@@ -2,7 +2,9 @@ use sqlx::{PgPool, Postgres, QueryBuilder};
use crate::{
db_persistence::DbError,
- models::raid_submission::{CreateRaidSubmission, RaidSubmission, UpdateRaidSubmissionStats},
+ models::raid_submission::{
+ CreateRaidSubmission, RaidSubmission, UpdateRaidSubmissionStats, ValidRaidSubmissionWithRaiderUsername,
+ },
repositories::DbResult,
};
@@ -62,12 +64,12 @@ impl RaidSubmissionRepository {
Ok(submission)
}
- pub async fn find_valid_only_by_raid(&self, raid_id: i32) -> DbResult> {
- let mut qb = Self::create_select_base_query();
- qb.push(" WHERE raid_id = ");
+ pub async fn find_valid_only_by_raid(&self, raid_id: i32) -> DbResult> {
+ let mut qb = QueryBuilder::new("SELECT rs.id as raid_submission_id, x.username as raider_username FROM raid_submissions rs INNER JOIN x_associations x ON rs.raider_id = x.quan_address");
+ qb.push(" WHERE rs.raid_id = ");
qb.push_bind(raid_id);
- qb.push(" AND NOT is_invalid");
- qb.push(" ORDER BY created_at DESC");
+ qb.push(" AND NOT rs.is_invalid");
+ qb.push(" ORDER BY rs.created_at DESC");
let submissions = qb.build_query_as().fetch_all(&self.pool).await?;
@@ -295,6 +297,15 @@ mod tests {
let repo = setup_test_repository().await;
let seed = seed_dependencies(&repo.pool).await;
+ // Seed x_association for the raider so the query can retrieve the username
+ let x_username = "test_raider_username";
+ sqlx::query("INSERT INTO x_associations (quan_address, username) VALUES ($1, $2)")
+ .bind(&seed.raider_id)
+ .bind(x_username)
+ .execute(&repo.pool)
+ .await
+ .expect("Failed to seed x_association");
+
// Create 3 submissions with slight delays to ensure distinct created_at timestamps
let sub1 = create_mock_submission_input(&seed);
repo.create(&sub1).await.unwrap();
@@ -312,11 +323,22 @@ mod tests {
assert_eq!(results.len(), 3);
- // Verify Sorting: Query uses "ORDER BY created_at DESC"
+ // Verify Sorting: Query uses "ORDER BY rs.created_at DESC"
// So sub3 (newest) should be first
- assert_eq!(results[0].id, sub3.id, "Newest submission should be first");
- assert_eq!(results[1].id, sub2.id);
- assert_eq!(results[2].id, sub1.id, "Oldest submission should be last");
+ assert_eq!(
+ results[0].raid_submission_id, sub3.id,
+ "Newest submission should be first"
+ );
+ assert_eq!(results[1].raid_submission_id, sub2.id);
+ assert_eq!(
+ results[2].raid_submission_id, sub1.id,
+ "Oldest submission should be last"
+ );
+
+ // Verify that usernames are correctly retrieved
+ assert_eq!(results[0].raider_username, x_username);
+ assert_eq!(results[1].raider_username, x_username);
+ assert_eq!(results[2].raider_username, x_username);
}
#[tokio::test]
diff --git a/src/services/raid_leaderboard_service.rs b/src/services/raid_leaderboard_service.rs
index 1b0db37..9dadbe8 100644
--- a/src/services/raid_leaderboard_service.rs
+++ b/src/services/raid_leaderboard_service.rs
@@ -4,14 +4,14 @@ use std::{
};
use rusx::{
- resources::{tweet::TweetParams, TweetField},
+ resources::{tweet::TweetParams, TweetExpansion, TweetField},
TwitterGateway,
};
use crate::{
db_persistence::DbPersistence,
metrics::{track_tweets_pulled, track_twitter_api_call},
- models::raid_submission::{RaidSubmission, UpdateRaidSubmissionStats},
+ models::raid_submission::{UpdateRaidSubmissionStats, ValidRaidSubmissionWithRaiderUsername},
services::alert_service::AlertService,
AppError, AppResult, Config,
};
@@ -25,13 +25,13 @@ pub struct RaidLeaderboardService {
}
impl RaidLeaderboardService {
- fn build_batched_tweet_queries(submissions: &[RaidSubmission]) -> Vec> {
+ fn build_batched_tweet_queries(submissions: &[ValidRaidSubmissionWithRaiderUsername]) -> Vec> {
// Twitter's limit for the get ids result
const TWEET_GET_MAX_IDS: usize = 100;
submissions
.chunks(TWEET_GET_MAX_IDS)
- .map(|chunk| chunk.iter().map(|s| s.id.clone()).collect())
+ .map(|chunk| chunk.iter().map(|s| s.raid_submission_id.clone()).collect())
.collect()
}
@@ -88,7 +88,10 @@ impl RaidLeaderboardService {
};
let queries = RaidLeaderboardService::build_batched_tweet_queries(&raid_submissions);
- let raider_map: HashMap = raid_submissions.into_iter().map(|s| (s.id, s.raider_id)).collect();
+ let submission_to_x_username_map: HashMap = raid_submissions
+ .into_iter()
+ .map(|s| (s.raid_submission_id, s.raider_username))
+ .collect();
let mut params = TweetParams::new();
params.tweet_fields = Some(vec![
@@ -98,6 +101,7 @@ impl RaidLeaderboardService {
TweetField::InReplyToUserId,
TweetField::ReferencedTweets,
]);
+ params.expansions = Some(vec![TweetExpansion::AuthorId]);
// X Api Request Limit: 15 requests / 15 mins.
// We set interval to 1 min (~1 req/min) to be safe.
@@ -119,6 +123,17 @@ impl RaidLeaderboardService {
tracing::info!("No tweets found!.");
continue;
};
+ let Some(includes) = &response.includes else {
+ tracing::info!("No includes found!.");
+ continue;
+ };
+ let Some(users) = &includes.users else {
+ tracing::info!("No users found!.");
+ continue;
+ };
+
+ let user_id_to_username_map: HashMap =
+ users.iter().map(|u| (u.id.clone(), u.username.clone())).collect();
// Track Twitter API usage (for alerting)
let tweets_pulled = tweets.len();
@@ -150,7 +165,13 @@ impl RaidLeaderboardService {
// Check if ANY of the referenced IDs exist in our valid set
refs.iter().any(|r| valid_raid_ids.contains(&r.id))
});
- let is_eligible_owner = raider_map.get(&tweet.id) == tweet.author_id.as_ref();
+ let is_eligible_owner = match (
+ tweet.author_id.as_ref().and_then(|id| user_id_to_username_map.get(id)),
+ submission_to_x_username_map.get(&tweet.id),
+ ) {
+ (Some(author), Some(expected)) => author.eq_ignore_ascii_case(expected),
+ _ => false,
+ };
if is_valid_reply && is_eligible_owner {
valid_tweets.push(tweet);
@@ -193,7 +214,8 @@ mod tests {
use rusx::{
resources::{
tweet::{ReferenceType, ReferencedTweet, Tweet, TweetApi, TweetPublicMetrics},
- TwitterApiResponse,
+ user::User,
+ Includes, TwitterApiResponse,
},
MockTweetApi, MockTwitterGateway,
};
@@ -242,6 +264,8 @@ mod tests {
raid_id: i32,
target_id: &str,
submission_id: &str,
+ x_username: &str,
+ x_user_id: &str,
) {
// 1. Seed Raider (Address)
// Handle constraint if address already exists from previous calls in same test
@@ -252,22 +276,34 @@ mod tests {
.execute(&db.pool)
.await;
- // 2. Seed Tweet Author (Foreign Key for RelevantTweet)
+ // 2. Seed X Association (Required for raider_id to X user ID mapping)
+ let _ = sqlx::query(
+ "INSERT INTO x_associations (quan_address, username) VALUES ($1, $2) ON CONFLICT (quan_address) DO UPDATE SET username = EXCLUDED.username",
+ )
+ .bind(raider_id)
+ .bind(x_username)
+ .execute(&db.pool)
+ .await;
+
+ // 3. Seed Tweet Author (Foreign Key for RelevantTweet)
let _ = sqlx::query(
- "INSERT INTO tweet_authors (id, name, username) VALUES ('auth_1', 'Auth', 'auth') ON CONFLICT DO NOTHING",
+ "INSERT INTO tweet_authors (id, name, username) VALUES ($1, 'Auth', $2) ON CONFLICT DO NOTHING",
)
+ .bind(x_user_id)
+ .bind(x_username)
.execute(&db.pool)
.await;
- // 3. Seed Relevant Tweet (Target)
+ // 4. Seed Relevant Tweet (Target)
let _ = sqlx::query(
- "INSERT INTO relevant_tweets (id, author_id, text, created_at) VALUES ($1, 'auth_1', 'Target', NOW())",
+ "INSERT INTO relevant_tweets (id, author_id, text, created_at) VALUES ($1, $2, 'Target', NOW())",
)
.bind(target_id)
+ .bind(x_user_id)
.execute(&db.pool)
.await;
- // 4. Create Submission
+ // 5. Create Submission
let _ = sqlx::query(
"INSERT INTO raid_submissions (id, raid_id, raider_id, impression_count, like_count)
VALUES ($1, $2, $3, 0, 0)",
@@ -337,28 +373,42 @@ mod tests {
let raider_id = "0xRaider";
let sub_id = "12345_submission";
let target_id = "target_12345_submission";
- seed_submission(&db, raider_id, raid_id, target_id, sub_id).await;
+ let x_username = "test_raider";
+ let x_user_id = "1234567890"; // X user ID
+ seed_submission(&db, raider_id, raid_id, target_id, sub_id, x_username, x_user_id).await;
// 3. Setup Mocks
let mut mock_gateway = MockTwitterGateway::new();
let mut mock_tweet_api = MockTweetApi::new();
// Expect get_many to be called with the submission ID
+ let x_user_id_clone = x_user_id.to_string();
+ let target_id_clone = target_id.to_string();
+ let sub_id_clone = sub_id.to_string();
mock_tweet_api
.expect_get_many()
.with(predicate::eq(vec![sub_id.to_string()]), predicate::always())
.times(1)
- .returning(|_, _| {
+ .returning(move |_, _| {
Ok(TwitterApiResponse {
// Return UPDATED stats (100 impressions, 50 likes)
data: Some(vec![create_mock_tweet(
- sub_id,
- target_id.to_string(),
- raider_id.to_string(),
+ &sub_id_clone,
+ target_id_clone.clone(),
+ x_user_id_clone.clone(), // Use X user ID, not raider_id
100,
50,
)]),
- includes: None,
+ includes: Some(Includes {
+ users: Some(vec![User {
+ id: x_user_id_clone.clone(),
+ username: x_username.to_string(),
+ name: "Test User".to_string(),
+ description: None,
+ public_metrics: None,
+ }]),
+ tweets: None,
+ }),
meta: None,
})
});
@@ -398,28 +448,41 @@ mod tests {
let raider_id = "0xRaider";
let sub_id = "12345_submission";
let target_id = "target_12345_submission";
- seed_submission(&db, raider_id, raid_id, target_id, sub_id).await;
+ let x_username = "test_raider";
+ let x_user_id = "1234567890"; // X user ID
+ seed_submission(&db, raider_id, raid_id, target_id, sub_id, x_username, x_user_id).await;
// 3. Setup Mocks
let mut mock_gateway = MockTwitterGateway::new();
let mut mock_tweet_api = MockTweetApi::new();
// Expect get_many to be called with the submission ID
+ let x_user_id_clone = x_user_id.to_string();
+ let sub_id_clone = sub_id.to_string();
mock_tweet_api
.expect_get_many()
.with(predicate::eq(vec![sub_id.to_string()]), predicate::always())
.times(1)
- .returning(|_, _| {
+ .returning(move |_, _| {
Ok(TwitterApiResponse {
// Return UPDATED stats (100 impressions, 50 likes)
data: Some(vec![create_mock_tweet(
- sub_id,
+ &sub_id_clone,
"invalid_id".to_string(),
- raider_id.to_string(),
+ x_user_id_clone.clone(), // Use X user ID, not raider_id
100,
50,
)]),
- includes: None,
+ includes: Some(Includes {
+ users: Some(vec![User {
+ id: x_user_id_clone.clone(),
+ username: "invalid_username".to_string(),
+ name: "Test User".to_string(),
+ description: None,
+ public_metrics: None,
+ }]),
+ tweets: None,
+ }),
meta: None,
})
});
@@ -460,9 +523,20 @@ mod tests {
// We just need unique IDs.
let mut all_ids = Vec::new();
let raider_id = "0xRaider";
+ let x_username = "test_raider";
+ let x_user_id = "1234567890"; // X user ID
for i in 0..150 {
let id = format!("sub_{}", i);
- seed_submission(&db, raider_id, raid_id, &format!("target_{}", id), &id).await;
+ seed_submission(
+ &db,
+ raider_id,
+ raid_id,
+ &format!("target_{}", id),
+ &id,
+ x_username,
+ x_user_id,
+ )
+ .await;
all_ids.push(id);
}
@@ -473,11 +547,12 @@ mod tests {
// We expect `get_many` to be called 2 times.
// 1st time: 100 IDs
// 2nd time: 50 IDs
- mock_tweet_api.expect_get_many().times(2).returning(|ids, _| {
+ let x_user_id_clone = x_user_id.to_string();
+ mock_tweet_api.expect_get_many().times(2).returning(move |ids, _| {
// Return valid responses for whatever IDs were requested
let tweets = ids
.iter()
- .map(|id| create_mock_tweet(id, format!("target_{}", id), raider_id.to_string(), 10, 1))
+ .map(|id| create_mock_tweet(id, format!("target_{}", id), x_user_id_clone.clone(), 10, 1))
.collect();
Ok(TwitterApiResponse {
data: Some(tweets),