···1+-- Add index for efficient RSVP queries by event, status, and time ordering
2+-- This supports queries that filter by event_aturi and status, then order by updated_at DESC
3+-- Enables fast pagination and counting without full table scans
4+CREATE INDEX IF NOT EXISTS idx_rsvps_event_status_updated
5+ON rsvps (event_aturi, status, updated_at DESC);
···980 )))
981}
982983+/// Grouped RSVP data with limited results per status and total counts
984+pub struct GroupedRsvpData {
985+ pub going: Vec<(String, String, Option<chrono::DateTime<chrono::Utc>>)>,
986+ pub interested: Vec<(String, String, Option<chrono::DateTime<chrono::Utc>>)>,
987+ pub notgoing: Vec<(String, String, Option<chrono::DateTime<chrono::Utc>>)>,
988+ pub going_total: u32,
989+ pub interested_total: u32,
990+ pub notgoing_total: u32,
991+}
992+993+/// Fetch RSVPs grouped by status with limits and total counts in a single optimized query
994+///
995+/// This function uses a window function to efficiently:
996+/// - Limit results per status (going: 500, interested: 200, notgoing: 100)
997+/// - Order by updated_at DESC for each status
998+/// - Return total counts for each status
999+/// - Execute as a single database query with one table scan
1000+///
1001+/// This is significantly more efficient than making 6 separate queries (3 for data + 3 for counts)
1002+pub async fn get_grouped_event_rsvps(
1003+ pool: &StoragePool,
1004+ event_aturi: &str,
1005+) -> Result<GroupedRsvpData, StorageError> {
1006+ // Validate event_aturi is not empty
1007+ if event_aturi.trim().is_empty() {
1008+ return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol(
1009+ "Event URI cannot be empty".into(),
1010+ )));
1011+ }
1012+1013+ let mut tx = pool
1014+ .begin()
1015+ .await
1016+ .map_err(StorageError::CannotBeginDatabaseTransaction)?;
1017+1018+ // Single query with window functions for efficient grouped pagination
1019+ // ROW_NUMBER partitions by status and orders by updated_at DESC
1020+ // COUNT(*) OVER gives us the total count per status
1021+ let rows = sqlx::query_as::<_, (String, String, Option<chrono::DateTime<chrono::Utc>>, i64)>(
1022+ r#"
1023+ WITH ranked_rsvps AS (
1024+ SELECT
1025+ did,
1026+ status,
1027+ validated_at,
1028+ ROW_NUMBER() OVER (PARTITION BY status ORDER BY updated_at DESC) as rn,
1029+ COUNT(*) OVER (PARTITION BY status) as status_count
1030+ FROM rsvps
1031+ WHERE event_aturi = $1
1032+ AND status IN ('going', 'interested', 'notgoing')
1033+ )
1034+ SELECT
1035+ did,
1036+ status,
1037+ validated_at,
1038+ status_count
1039+ FROM ranked_rsvps
1040+ WHERE (status = 'going' AND rn <= 500)
1041+ OR (status = 'interested' AND rn <= 200)
1042+ OR (status = 'notgoing' AND rn <= 100)
1043+ ORDER BY
1044+ CASE status
1045+ WHEN 'going' THEN 1
1046+ WHEN 'interested' THEN 2
1047+ WHEN 'notgoing' THEN 3
1048+ END,
1049+ rn
1050+ "#,
1051+ )
1052+ .bind(event_aturi)
1053+ .fetch_all(tx.as_mut())
1054+ .await
1055+ .map_err(StorageError::UnableToExecuteQuery)?;
1056+1057+ tx.commit()
1058+ .await
1059+ .map_err(StorageError::CannotCommitDatabaseTransaction)?;
1060+1061+ // Separate rows by status and extract counts
1062+ let mut going = Vec::new();
1063+ let mut interested = Vec::new();
1064+ let mut notgoing = Vec::new();
1065+ let mut going_total: u32 = 0;
1066+ let mut interested_total: u32 = 0;
1067+ let mut notgoing_total: u32 = 0;
1068+1069+ for (did, status, validated_at, count) in rows {
1070+ let count_u32 = count as u32;
1071+ match status.as_str() {
1072+ "going" => {
1073+ if going_total == 0 {
1074+ going_total = count_u32;
1075+ }
1076+ going.push((did, status, validated_at));
1077+ }
1078+ "interested" => {
1079+ if interested_total == 0 {
1080+ interested_total = count_u32;
1081+ }
1082+ interested.push((did, status, validated_at));
1083+ }
1084+ "notgoing" => {
1085+ if notgoing_total == 0 {
1086+ notgoing_total = count_u32;
1087+ }
1088+ notgoing.push((did, status, validated_at));
1089+ }
1090+ _ => {} // Ignore unknown statuses
1091+ }
1092+ }
1093+1094+ Ok(GroupedRsvpData {
1095+ going,
1096+ interested,
1097+ notgoing,
1098+ going_total,
1099+ interested_total,
1100+ notgoing_total,
1101+ })
1102+}
1103+1104pub(crate) async fn event_list(
1105 pool: &StoragePool,
1106 page: i64,