diff --git a/migrations/007_raid_submissions_target_id_nullable.sql b/migrations/007_raid_submissions_target_id_nullable.sql
new file mode 100644
index 0000000..e1e1629
--- /dev/null
+++ b/migrations/007_raid_submissions_target_id_nullable.sql
@@ -0,0 +1,2 @@
+ALTER TABLE raid_submissions
+ALTER COLUMN target_id DROP NOT NULL;
\ No newline at end of file
diff --git a/src/handlers/raid_quest.rs b/src/handlers/raid_quest.rs
index 1af0237..4cfa5ff 100644
--- a/src/handlers/raid_quest.rs
+++ b/src/handlers/raid_quest.rs
@@ -190,21 +190,6 @@ pub async fn handle_create_raid_submission(
Extension(user): Extension
,
extract::Json(payload): Json,
) -> Result<(StatusCode, Json>), AppError> {
- let Some((_target_username, target_id)) = parse_x_status_url(&payload.target_tweet_link) else {
- return Err(AppError::Handler(HandlerError::InvalidBody(format!(
- "Couldn't parse target tweet link"
- ))));
- };
- let Some(_) = state.db.relevant_tweets.find_by_id(&target_id).await? else {
- return Err(AppError::Database(DbError::RecordNotFound(format!(
- "Not a valid target tweet"
- ))));
- };
- let Some((reply_username, reply_id)) = parse_x_status_url(&payload.tweet_reply_link) else {
- return Err(AppError::Handler(HandlerError::InvalidBody(format!(
- "Couldn't parse tweet reply link"
- ))));
- };
let Some(current_active_raid) = state.db.raid_quests.find_active().await? else {
return Err(AppError::Database(DbError::RecordNotFound(format!(
"No active raid is found"
@@ -215,6 +200,11 @@ pub async fn handle_create_raid_submission(
"User doesn't have X association"
))));
};
+ let Some((reply_username, reply_id)) = parse_x_status_url(&payload.tweet_reply_link) else {
+ return Err(AppError::Handler(HandlerError::InvalidBody(format!(
+ "Couldn't parse tweet reply link"
+ ))));
+ };
if user_x.username != reply_username {
return Err(AppError::Handler(HandlerError::Auth(AuthHandlerError::Unauthorized(
format!("Only tweet reply author is eligible to submit"),
@@ -225,7 +215,6 @@ pub async fn handle_create_raid_submission(
id: reply_id,
raid_id: current_active_raid.id,
raider_id: user.quan_address.0,
- target_id: target_id,
};
let created_id = state.db.raid_submissions.create(&new_raid_submission).await?;
@@ -570,10 +559,8 @@ mod tests {
.with_state(state.clone());
// 5. Payload
- // Target Link -> ID 1868000000000000000
// Reply Link -> ID 999999999, Username "me"
let payload = RaidSubmissionInput {
- target_tweet_link: format!("https://x.com/someone/status/{}", target_tweet_id),
tweet_reply_link: "https://x.com/me/status/999999999".to_string(),
};
@@ -596,7 +583,8 @@ mod tests {
assert!(sub.is_some());
let sub = sub.unwrap();
assert_eq!(sub.raid_id, raid_id);
- assert_eq!(sub.target_id, target_tweet_id);
+ assert_eq!(&sub.id, "999999999");
+ assert!(sub.target_id.is_none());
}
#[tokio::test]
@@ -621,7 +609,6 @@ mod tests {
.with_state(state);
let payload = RaidSubmissionInput {
- target_tweet_link: "https://x.com/a/status/100".into(),
tweet_reply_link: "https://x.com/b/status/200".into(),
};
@@ -660,8 +647,7 @@ mod tests {
.with_state(state);
let payload = RaidSubmissionInput {
- target_tweet_link: "not_a_valid_url".into(),
- tweet_reply_link: "https://x.com/b/status/200".into(),
+ tweet_reply_link: "https://x.com/b/dwdwdwt/dwdwd".into(),
};
let response = router
diff --git a/src/models/raid_submission.rs b/src/models/raid_submission.rs
index ffa1b61..2e49489 100644
--- a/src/models/raid_submission.rs
+++ b/src/models/raid_submission.rs
@@ -9,7 +9,7 @@ use crate::models::raid_quest::RaidQuest;
pub struct RaidSubmission {
pub id: String,
pub raid_id: i32,
- pub target_id: String,
+ pub target_id: Option,
pub raider_id: String,
pub impression_count: i32,
pub reply_count: i32,
@@ -54,13 +54,11 @@ impl<'r> FromRow<'r, PgRow> for RaidSubmission {
pub struct CreateRaidSubmission {
pub id: String,
pub raid_id: i32,
- pub target_id: String,
pub raider_id: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RaidSubmissionInput {
- pub target_tweet_link: String,
pub tweet_reply_link: String,
}
diff --git a/src/repositories/raid_submission.rs b/src/repositories/raid_submission.rs
index 2d65a42..e4ba71c 100644
--- a/src/repositories/raid_submission.rs
+++ b/src/repositories/raid_submission.rs
@@ -24,15 +24,14 @@ impl RaidSubmissionRepository {
let created_id = sqlx::query_scalar::<_, String>(
"
INSERT INTO raid_submissions (
- id, raid_id, target_id, raider_id
+ id, raid_id, raider_id
)
- VALUES ($1, $2, $3, $4)
+ VALUES ($1, $2, $3)
RETURNING id
",
)
.bind(&submission.id)
.bind(submission.raid_id)
- .bind(&submission.target_id)
.bind(&submission.raider_id)
.fetch_optional(&self.pool)
.await?;
@@ -186,7 +185,6 @@ mod tests {
struct SeedData {
raid_id: i32,
raider_id: String,
- target_id: String,
}
// Helper to satisfy the strict Foreign Key chain:
@@ -232,18 +230,13 @@ mod tests {
.await
.expect("Failed to seed relevant tweet");
- SeedData {
- raid_id,
- raider_id,
- target_id,
- }
+ SeedData { raid_id, raider_id }
}
fn create_mock_submission_input(seed: &SeedData) -> CreateRaidSubmission {
CreateRaidSubmission {
id: Uuid::new_v4().to_string(),
raid_id: seed.raid_id,
- target_id: seed.target_id.clone(),
raider_id: seed.raider_id.clone(),
}
}
@@ -423,7 +416,6 @@ mod tests {
let input = CreateRaidSubmission {
id: Uuid::new_v4().to_string(),
raid_id: 9999, // Non-existent Raid
- target_id: "fake_tweet".to_string(),
raider_id: "fake_user".to_string(),
};
diff --git a/src/services/raid_leaderboard_service.rs b/src/services/raid_leaderboard_service.rs
index 44509bc..5311a63 100644
--- a/src/services/raid_leaderboard_service.rs
+++ b/src/services/raid_leaderboard_service.rs
@@ -1,4 +1,7 @@
-use std::{collections::HashSet, sync::Arc};
+use std::{
+ collections::{HashMap, HashSet},
+ sync::Arc,
+};
use rusx::{
resources::{tweet::TweetParams, TweetField},
@@ -76,6 +79,7 @@ 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 mut params = TweetParams::new();
params.tweet_fields = Some(vec![
@@ -122,12 +126,13 @@ impl RaidLeaderboardService {
// `for tweet in tweets` consumes the original vector, so we "move"
// the data instead of cloning it.
for tweet in tweets {
- let is_valid = tweet.referenced_tweets.as_ref().map_or(false, |refs| {
+ let is_valid_reply = tweet.referenced_tweets.as_ref().map_or(false, |refs| {
// 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();
- if is_valid {
+ if is_valid_reply && is_eligible_owner {
valid_tweets.push(tweet);
} else {
invalid_tweets.push(tweet);
@@ -187,11 +192,11 @@ mod tests {
(db, Arc::new(config))
}
- fn create_mock_tweet(id: &str, target_id: String, impressions: u32, likes: u32) -> Tweet {
+ fn create_mock_tweet(id: &str, target_id: String, author_id: String, impressions: u32, likes: u32) -> Tweet {
Tweet {
id: id.to_string(),
text: "Raid content".to_string(),
- author_id: Some("author_1".to_string()),
+ author_id: Some(author_id),
created_at: Some(chrono::Utc::now().to_rfc3339()),
in_reply_to_user_id: None,
referenced_tweets: Some(vec![ReferencedTweet {
@@ -209,9 +214,14 @@ mod tests {
}
// Helper to seed the DB requirements for a submission
- async fn seed_submission(db: &Arc, raid_id: i32, target_id: &str, submission_id: &str) {
+ async fn seed_submission(
+ db: &Arc,
+ raider_id: &str,
+ raid_id: i32,
+ target_id: &str,
+ submission_id: &str,
+ ) {
// 1. Seed Raider (Address)
- let raider_id = "0xRaider";
// Handle constraint if address already exists from previous calls in same test
let _ = sqlx::query(
"INSERT INTO addresses (quan_address, referral_code) VALUES ($1, 'REF') ON CONFLICT DO NOTHING",
@@ -237,12 +247,11 @@ mod tests {
// 4. Create Submission
let _ = sqlx::query(
- "INSERT INTO raid_submissions (id, raid_id, target_id, raider_id, impression_count, like_count)
- VALUES ($1, $2, $3, $4, 0, 0)",
+ "INSERT INTO raid_submissions (id, raid_id, raider_id, impression_count, like_count)
+ VALUES ($1, $2, $3, 0, 0)",
)
.bind(submission_id)
.bind(raid_id)
- .bind(target_id)
.bind(raider_id)
.execute(&db.pool)
.await
@@ -303,9 +312,10 @@ mod tests {
.unwrap();
// 2. Seed Submission (Initial Stats: 0 impressions, 0 likes)
+ let raider_id = "0xRaider";
let sub_id = "12345_submission";
let target_id = "target_12345_submission";
- seed_submission(&db, raid_id, target_id, sub_id).await;
+ seed_submission(&db, raider_id, raid_id, target_id, sub_id).await;
// 3. Setup Mocks
let mut mock_gateway = MockTwitterGateway::new();
@@ -319,7 +329,13 @@ mod tests {
.returning(|_, _| {
Ok(TwitterApiResponse {
// Return UPDATED stats (100 impressions, 50 likes)
- data: Some(vec![create_mock_tweet(sub_id, target_id.to_string(), 100, 50)]),
+ data: Some(vec![create_mock_tweet(
+ sub_id,
+ target_id.to_string(),
+ raider_id.to_string(),
+ 100,
+ 50,
+ )]),
includes: None,
meta: None,
})
@@ -343,6 +359,67 @@ mod tests {
assert_eq!(updated_sub.like_count, 50);
}
+ #[tokio::test]
+ async fn test_sync_flag_invalid() {
+ let (db, config) = setup_deps().await;
+
+ // 1. Create Active Raid
+ let raid_id = db
+ .raid_quests
+ .create(&CreateRaidQuest {
+ name: "Active Raid".to_string(),
+ })
+ .await
+ .unwrap();
+
+ // 2. Seed Submission (Initial Stats: 0 impressions, 0 likes)
+ 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;
+
+ // 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
+ mock_tweet_api
+ .expect_get_many()
+ .with(predicate::eq(vec![sub_id.to_string()]), predicate::always())
+ .times(1)
+ .returning(|_, _| {
+ Ok(TwitterApiResponse {
+ // Return UPDATED stats (100 impressions, 50 likes)
+ data: Some(vec![create_mock_tweet(
+ sub_id,
+ "invalid_id".to_string(),
+ raider_id.to_string(),
+ 100,
+ 50,
+ )]),
+ includes: None,
+ meta: None,
+ })
+ });
+
+ mock_gateway
+ .expect_tweets()
+ .return_const(Arc::new(mock_tweet_api) as Arc);
+
+ let service = RaidLeaderboardService::new(db.clone(), Arc::new(mock_gateway), config);
+
+ // 4. Run Sync
+ service.sync_raid_leaderboard().await.unwrap();
+
+ // 5. Verify DB Updated
+ let updated_sub = db.raid_submissions.find_by_id(sub_id).await.unwrap().unwrap();
+
+ assert!(updated_sub.updated_at > updated_sub.created_at);
+ assert_eq!(updated_sub.is_invalid, true);
+ assert_eq!(updated_sub.impression_count, 0);
+ assert_eq!(updated_sub.like_count, 0);
+ }
+
#[tokio::test]
async fn test_sync_batching_logic() {
// This test verifies that if we have > 100 submissions,
@@ -360,9 +437,10 @@ mod tests {
// 1. Seed 150 Submissions
// We just need unique IDs.
let mut all_ids = Vec::new();
+ let raider_id = "0xRaider";
for i in 0..150 {
let id = format!("sub_{}", i);
- seed_submission(&db, raid_id, &format!("target_{}", id), &id).await;
+ seed_submission(&db, raider_id, raid_id, &format!("target_{}", id), &id).await;
all_ids.push(id);
}
@@ -377,7 +455,7 @@ mod tests {
// Return valid responses for whatever IDs were requested
let tweets = ids
.iter()
- .map(|id| create_mock_tweet(id, format!("target_{}", id), 10, 1))
+ .map(|id| create_mock_tweet(id, format!("target_{}", id), raider_id.to_string(), 10, 1))
.collect();
Ok(TwitterApiResponse {
data: Some(tweets),