diff --git a/Cargo.lock b/Cargo.lock
index 5290b50..f753494 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -829,6 +829,27 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "csv"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
+dependencies = [
+ "csv-core",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
+[[package]]
+name = "csv-core"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "darling"
version = "0.20.11"
@@ -3027,6 +3048,7 @@ dependencies = [
"base64 0.22.1",
"chrono",
"console_error_panic_hook",
+ "csv",
"icondata",
"indexmap",
"leptos",
diff --git a/Cargo.toml b/Cargo.toml
index c29aaf5..401029d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,6 +30,7 @@ traits = { path = "./traits" }
icondata = { version = "0.7.0", features = ["font-awesome"] }
leptos_icons = "0.7.1"
indexmap = { version = "2.13.0", features = ["serde"] }
+csv = "1.4.0"
[features]
default = ["ssr"]
diff --git a/src/app.rs b/src/app.rs
index 7170ee1..75517b3 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -74,6 +74,7 @@ pub fn AppWithRoutes() -> impl IntoView {
+
diff --git a/src/pages/admin/utils_page.rs b/src/pages/admin/utils_page.rs
index 7b8e7d1..202f770 100644
--- a/src/pages/admin/utils_page.rs
+++ b/src/pages/admin/utils_page.rs
@@ -1,17 +1,21 @@
-use chrono::DateTime;
+use crate::common::{DateInfo, DateRange};
use crate::components::ActionButton;
+use crate::pages::api::exports::get_scholarship_csv;
+use crate::pages::api::{CreateDates, CreateTestComparisons, exports::GetScholarshipCsv};
+use chrono::DateTime;
use leptos::prelude::*;
-use crate::common::{DateInfo, DateRange};
-use crate::pages::api::{CreateDates, CreateTestComparisons};
+use leptos::task::spawn_local;
+use leptos::wasm_bindgen::JsCast;
+use leptos::web_sys::{Blob, HtmlAnchorElement, Url, js_sys};
fn get_important_dates() -> Vec {
- vec! [
+ vec![
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Provider Forms Open".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-01-20T07:15:00-05:00").unwrap(),
- DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap()
+ DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap(),
),
description: "".to_string(),
},
@@ -20,7 +24,7 @@ fn get_important_dates() -> Vec {
title: "Student Forms Open".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-03-03T07:15:00-05:00").unwrap(),
- DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap()
+ DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap(),
),
description: "".to_string(),
},
@@ -29,7 +33,7 @@ fn get_important_dates() -> Vec {
title: "Scholarship Decision Window".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-04-10T07:15:00-05:00").unwrap(),
- DateTime::parse_from_rfc3339("2026-05-01T23:59:00-05:00").unwrap()
+ DateTime::parse_from_rfc3339("2026-05-01T23:59:00-05:00").unwrap(),
),
description: "Start date may change if available sooner.".to_string(),
},
@@ -52,127 +56,48 @@ fn get_important_dates() -> Vec {
]
}
-#[server]
-async fn create_scholarship_export() -> Result, ServerFnError> {
- // Exports olarship information - specifically the name of the scholarship, the name of the
- // provider, and the scholarship's requirements.
- // This means we'll need to get all the relations, all the scholarships, and all the contact info.
- use crate::utils::server::create_dynamo_client;
- use aws_sdk_dynamodb::client::Client;
- use crate::pages::api::{get_comparison_info, SCHOLARSHIPS_TABLE, PROVIDER_CONTACT_TABLE};
- use std::collections::HashMap;
- use crate::common::ValueType;
-
- let scan_table_items = async |client: &Client, table_name: &str| {
- client.scan()
- .table_name(table_name)
- .send()
- .await
- .ok().and_then(|res| res.items)
- .unwrap_or_default()
- };
-
- #[derive(serde::Serialize, serde::Deserialize, Debug)]
- struct ScholarshipInfo {
- name: String,
- provider_name: String,
- relations: Vec,
- }
-
- let client = create_dynamo_client().await;
-
- let scholarships = dbg!(scan_table_items(&client, SCHOLARSHIPS_TABLE)
- .await
- .iter().map(|item| {
- item.into_iter()
- .map(|(k, v)| (k.clone(), ValueType::from(v)))
- .collect()
- }).collect::>>()
- );
- let contacts = dbg!(
- scan_table_items(&client, PROVIDER_CONTACT_TABLE).await
- .iter().map(|item| {
- item.into_iter()
- .map(|(k, v)| (k.clone(), ValueType::from(v)))
- .collect()
- }).collect::>>()
- );
- let db_relations = dbg!(
- get_comparison_info().await.unwrap_or_default()
- );
-
- // Build a new structure containing some of the scholarship's information.
- let resolved = scholarships.into_iter()
- .map(|scholarship| {
- // We just want the scholarship's name, the relations, and the provider's name.
- // In here, we're going to resolve all the scholarship's information to provide this in
- // a readable format.
- let name = scholarship.get("name")
- .unwrap_or(&ValueType::String(None))
- .to_string();
-
- // The list of relations - we're going to search the master list for each comparison's
- // name. It's structured as a map of lists, or a ValueType::Map(Option>)>>)
- let relations = scholarship.get("requirements")
- .unwrap_or(&ValueType::Map(None))
- .as_map().ok().flatten()
- .unwrap_or_default() // We have our map of categories, now we need all the values inside each category.
- .iter()
- .map(|(_, list)| {
- list.as_list().ok().flatten()
- .unwrap_or_default()
- })
- .flatten()
- .collect::>() // This is a Vec
- .iter().map(|val| val.clone().as_string().ok().flatten().unwrap_or_default())
- .collect::>();
-
- // The provider ID, which we'll use to search for the provider's name.
- let provider_id = scholarship.get("provider_id")
- .unwrap_or(&ValueType::String(None))
- .to_string();
-
- // This is a mess, we'll clean it up later.
- let parsed_relations = relations
- .iter()
- .filter_map(|relation| {
- db_relations.iter().find(|search| {
- search.id == *relation
- })
- .and_then(|search| Some(search.display_text.clone()))
- })
- .collect::>();
-
- ScholarshipInfo {
- name,
- provider_name: provider_id,
- relations: parsed_relations,
- }
- })
- .collect::>();
-
- // TODO Convert into CSV and send to client. For now I think I'm just going to serialize to json and print it.
- leptos::logging::log!("Scholarships list: {:?}", resolved);
-
- Ok(Vec::new())
-}
-
#[component]
pub fn AdminUtilsPage() -> impl IntoView {
let create_comparisons = ServerAction::::new();
let create_dates = ServerAction::::new();
- let create_export = ServerAction::::new();
+ let create_export = ServerAction::::new();
let on_click_comparisons = move |_| {
- create_comparisons.dispatch(CreateTestComparisons{});
+ create_comparisons.dispatch(CreateTestComparisons {});
};
let on_click_dates = move |_| {
- create_dates.dispatch(CreateDates{dates: get_important_dates()});
+ create_dates.dispatch(CreateDates {
+ dates: get_important_dates(),
+ });
};
let on_click_export = move |_| {
- create_export.dispatch(CreateScholarshipExport{});
+ spawn_local(async move {
+ let file_bytes = get_scholarship_csv().await.unwrap_or_default();
+
+ let uint_arr = js_sys::Uint8Array::new_with_length(file_bytes.len() as u32);
+ uint_arr.copy_from(&file_bytes);
+
+ let blob_parts = js_sys::Array::new();
+ blob_parts.push(&uint_arr);
+
+ let blob = Blob::new_with_u8_array_sequence(&blob_parts).unwrap();
+
+ let blob_url = Url::create_object_url_with_blob(&blob).unwrap();
+
+ let window = window();
+ let document = window.document().unwrap();
+ let a: HtmlAnchorElement = document.create_element("a").unwrap().dyn_into().unwrap();
+
+ a.set_href(&blob_url);
+ a.set_download("scholarships.csv");
+ document.body().unwrap().append_child(&a).unwrap();
+ a.click();
+
+ document.body().unwrap().remove_child(&a).unwrap();
+ Url::revoke_object_url(&blob_url).unwrap();
+ });
};
view! {
diff --git a/src/pages/api/exports.rs b/src/pages/api/exports.rs
new file mode 100644
index 0000000..54a39f3
--- /dev/null
+++ b/src/pages/api/exports.rs
@@ -0,0 +1,237 @@
+#[cfg(feature = "ssr")]
+mod imports {
+ pub use super::super::{COMPARISONS_TABLE, PROVIDER_CONTACT_TABLE, SCHOLARSHIPS_TABLE};
+ pub use crate::common::ValueType;
+ pub use crate::utils::server::create_dynamo_client;
+ pub use aws_sdk_dynamodb::Client;
+ pub use aws_sdk_dynamodb::error::ProvideErrorMetadata;
+ pub use indexmap::IndexMap;
+ pub use std::collections::HashMap;
+}
+
+use crate::common::ExpandableInfo;
+use leptos::prelude::*;
+
+#[server]
+pub async fn get_scholarship_csv() -> Result, ServerFnError> {
+ use imports::*;
+ use leptos::logging::debug_log;
+
+ #[derive(serde::Serialize)]
+ struct ResolvedScholarshipInfo {
+ contact_email: String,
+ first_name: String,
+ last_name: String,
+ scholarship_name: String,
+ sports_requirements: String,
+ gpa_requirements: String,
+ major_requirements: String,
+ additional_requirements: String,
+ community_requirements: String,
+ residency_requirements: String,
+ }
+
+ // Get the scholarships and comparisons
+ let client = create_dynamo_client().await;
+
+ let scan_table = async |client: &Client, table_name| {
+ client
+ .scan()
+ .table_name(table_name)
+ .send()
+ .await
+ .map(|output| output.items)
+ .ok()
+ .flatten()
+ .unwrap_or_default()
+ .into_iter()
+ .map(|item| {
+ serde_dynamo::from_item(item).unwrap_or(ExpandableInfo::new("".to_string()))
+ // item.iter()
+ // .map(|(k, v)| (k.to_owned(), ValueType::from(v)))
+ // .collect::>()
+ })
+ .collect::>()
+ };
+
+ let scan_table_hash_map = async |client: &Client, table_name| {
+ client
+ .scan()
+ .table_name(table_name)
+ .send()
+ .await
+ .map(|output| output.items)
+ .ok()
+ .flatten()
+ .unwrap_or_default()
+ .into_iter()
+ .map(|item| {
+ // serde_dynamo::from_item(item).unwrap_or(ExpandableInfo::new("".to_string()))
+ item.iter()
+ .map(|(k, v)| (k.to_owned(), ValueType::from(v)))
+ .collect::>()
+ })
+ .collect::>>()
+ };
+
+ let master_scholarships = scan_table(&client, SCHOLARSHIPS_TABLE).await;
+ let master_contacts = scan_table_hash_map(&client, PROVIDER_CONTACT_TABLE).await;
+ let master_relations = scan_table_hash_map(&client, COMPARISONS_TABLE).await;
+
+ // We now want to map the relations to the scholarships, as well as the contacts.
+ // This should take the information from the current scholarship, then relate the IDs of the
+ // requirements in the scholarship to the IDs of the relations in the master list.
+ let resolved_scholarships = master_scholarships
+ .into_iter()
+ .map(|scholarship| {
+ // debug_log!("Scholarship: {:?}", scholarship);
+ // We want the requirements IDs and the provider ID.
+ let requirements_map = scholarship
+ .data
+ .get("requirements")
+ .unwrap_or(&ValueType::Map(None))
+ .as_map()
+ .ok()
+ .flatten()
+ .unwrap_or_default();
+
+ let provider_id = scholarship
+ .data
+ .get("provider_id")
+ .map(|v| v.as_string().ok().flatten().unwrap_or_default())
+ .unwrap_or_default();
+
+ // Search for the provider ID in the master list.
+ let contact_info = master_contacts
+ .iter()
+ .find(|contact| {
+ let subject = contact
+ .get("subject")
+ .map(|v| v.as_string().ok().flatten().unwrap_or_default())
+ .unwrap_or_default();
+
+ debug_log!("Comparing {:?} and {:?}", subject, provider_id);
+
+ subject == provider_id
+ })
+ .cloned()
+ .unwrap_or_default();
+
+ // Resolve the requirements - this should resolve as another category-separated map,
+ // but with requirement display text instead of IDs.
+ let resolved_requirements = requirements_map
+ .iter()
+ .map(|(category, selected_val)| {
+ let selected_list = selected_val.as_list().ok().flatten().unwrap_or_default();
+
+ let resolved_names = selected_list
+ .into_iter()
+ .map(|id_val| {
+ let id = id_val.as_string().ok().flatten().unwrap_or_default();
+
+ let resolved_relation = master_relations
+ .iter()
+ .find(|item| {
+ let comp_id = item
+ .get("id")
+ .map(|v| v.as_string().ok().flatten().unwrap_or_default())
+ .unwrap_or_default();
+
+ comp_id == id
+ })
+ .cloned()
+ .map(|v| {
+ v.get("display_text").map(|text_opt| {
+ text_opt.as_string().ok().flatten().unwrap_or_default()
+ })
+ })
+ .flatten()
+ .unwrap_or_default();
+
+ resolved_relation
+ })
+ .collect::>();
+
+ (category.clone(), resolved_names)
+ })
+ .collect::>>();
+ debug_log!("Resolved requirements: {:?}", resolved_requirements);
+
+ let contact_email = contact_info
+ .get("contact_email")
+ .map(|v| v.as_string().ok().flatten().unwrap_or_default())
+ .unwrap_or_default();
+
+ let first_name = contact_info
+ .get("first_name")
+ .map(|v| v.as_string().ok().flatten().unwrap_or_default())
+ .unwrap_or_default();
+
+ let last_name = contact_info
+ .get("last_name")
+ .map(|v| v.as_string().ok().flatten().unwrap_or_default())
+ .unwrap_or_default();
+
+ let scholarship_name = scholarship
+ .data
+ .get("name")
+ .map(|v| v.as_string().ok().flatten().unwrap_or_default())
+ .unwrap_or_default();
+
+ debug_log!(
+ "{:?}, {:?}, {:?}, {:?}",
+ scholarship_name,
+ contact_email,
+ first_name,
+ last_name
+ );
+
+ // We want to create some structure that's got the correct information. Specifically,
+ // we only need the provider contact info, the scholarship's name, and the requirements
+ // that they chose in plain text.
+ ResolvedScholarshipInfo {
+ first_name,
+ last_name,
+ contact_email,
+ scholarship_name,
+ sports_requirements: resolved_requirements
+ .get("sports_participation")
+ .map(|list| list.join("; "))
+ .unwrap_or_default(),
+ gpa_requirements: resolved_requirements
+ .get("gpa")
+ .map(|list| list.join("; "))
+ .unwrap_or_default(),
+ major_requirements: resolved_requirements
+ .get("major")
+ .map(|list| list.join("; "))
+ .unwrap_or_default(),
+ additional_requirements: resolved_requirements
+ .get("misc")
+ .map(|list| list.join("; "))
+ .unwrap_or_default(),
+ community_requirements: resolved_requirements
+ .get("community_service")
+ .map(|list| list.join("; "))
+ .unwrap_or_default(),
+ residency_requirements: resolved_requirements
+ .get("residency")
+ .map(|list| list.join("; "))
+ .unwrap_or_default(),
+ }
+ })
+ .collect::>();
+
+ // Now that we have a series of scholarships, we need to serialize them into CSV and send the
+ // file to the client.
+ let mut writer = csv::Writer::from_writer(Vec::new());
+ resolved_scholarships.iter().for_each(|scholarship| {
+ _ = writer.serialize(scholarship);
+ });
+ writer.flush()?;
+
+ let result = writer.into_inner()?;
+ debug_log!("{:?}", String::from_utf8(result.clone()));
+
+ Ok(result)
+}
diff --git a/src/pages/api/mod.rs b/src/pages/api/mod.rs
index fb8c5e8..ae7606f 100644
--- a/src/pages/api/mod.rs
+++ b/src/pages/api/mod.rs
@@ -1,15 +1,16 @@
-mod comparisons;
-mod scholarships;
-mod dates;
mod admin;
+mod comparisons;
+mod dates;
+pub mod exports;
mod providers;
+mod scholarships;
pub mod students;
-pub use comparisons::*;
-pub use scholarships::*;
-pub use dates::*;
pub use admin::*;
+pub use comparisons::*;
+pub use dates::*;
pub use providers::*;
+pub use scholarships::*;
#[cfg(feature = "ssr")]
pub static COMPARISONS_TABLE: &str = "leptos-comparisons";