From 3cc67cb9e6c404c8903246c6c00dfc44852e9e40 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Sat, 25 Apr 2026 18:06:38 -0400 Subject: [PATCH 1/4] feat: add new admin APIs and applicants page --- src/app.rs | 11 +- src/common/user.rs | 2 +- src/pages/admin/applicants_list.rs | 579 +++++++++++++++++++++++++++++ src/pages/admin/mod.rs | 2 + src/pages/admin/shell.rs | 1 + src/pages/api/files.rs | 6 +- src/pages/api/students.rs | 77 +++- src/pages/api/tokens.rs | 13 +- src/pages/provider_applicants.rs | 32 +- 9 files changed, 677 insertions(+), 46 deletions(-) create mode 100644 src/pages/admin/applicants_list.rs diff --git a/src/app.rs b/src/app.rs index fd98e09..10c4c98 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,6 @@ use crate::components::ToastList; use crate::components::login::ProviderLoginContext; -use crate::pages::{ - AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminShell, AdminUtilsPage, - ApplicantsPageFallback, ApplicantsStudentList, AuthCallbackPage, LoanerBorrowForm, - LoanerFallback, LoanerReturnForm, LoanerShell, ProviderContactPage, ProviderPortal, - ScholarshipInfoPage, -}; +use crate::pages::{AdminApplicantsPageFallback, AdminApplicantsPageShell, AdminApplicantsStudentList, AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminShell, AdminUtilsPage, ApplicantsPageFallback, ApplicantsStudentList, AuthCallbackPage, LoanerBorrowForm, LoanerFallback, LoanerReturnForm, LoanerShell, ProviderContactPage, ProviderPortal, ScholarshipInfoPage}; use crate::pages::{ApplicantsPageShell, student}; use leptos::prelude::*; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; @@ -126,6 +121,10 @@ fn AdminRoutes() -> impl MatchNestedRoutes + Clone { + + + + } .into_inner() diff --git a/src/common/user.rs b/src/common/user.rs index 525572c..7ebae51 100644 --- a/src/common/user.rs +++ b/src/common/user.rs @@ -25,6 +25,6 @@ pub struct UserClaims { /// The intended use for the token. pub token_use: String, /// The list of the names of user pool groups that have this user as a member. - #[serde(rename = "cognito:groups")] + #[serde(rename = "cognito:groups", default = "Vec::new")] pub groups: Vec, } diff --git a/src/pages/admin/applicants_list.rs b/src/pages/admin/applicants_list.rs new file mode 100644 index 0000000..9a906d5 --- /dev/null +++ b/src/pages/admin/applicants_list.rs @@ -0,0 +1,579 @@ +use crate::common::{ + ComparisonData, ExpandableInfo, SchemaContainerStyle, SchemaHeaderStyle, SchemaNode, + SchemaType, ValueType, +}; +use crate::components::{ActionButton, Banner, DataDisplay, Loading}; +use crate::pages::{StudentInformationDialog, UnauthenticatedPage}; +use crate::pages::api::get_comparison_info; +use crate::pages::api::students::{GetStudentFiles, admin_get_all_input_files, get_student_data}; +use base64::Engine; +use leptos::ev::{Event, MouseEvent}; +use leptos::html::Dialog; +use leptos::logging::debug_log; +use leptos::prelude::*; +use leptos::wasm_bindgen::JsCast; +use leptos::web_sys; +use leptos::web_sys::{HtmlAnchorElement, ScrollBehavior}; +use leptos_icons::Icon; +use leptos_oidc::{AuthLoaded, AuthSignal, Authenticated}; +use leptos_router::components::Outlet; +use leptos_router::hooks::use_navigate; +use std::collections::HashMap; +use leptos_router::NavigateOptions; + +#[component] +pub fn AdminApplicantsPageFallback() -> impl IntoView { + view! { +
+

"Scholarship Applicants"

+

+ "Please select a scholarship from the left to view the eligible applicants." +

+
+ } +} + +#[component] +pub fn AdminApplicantsPageShell() -> impl IntoView { + // This shell needs to simply contain everything that will stay consistent across paths. + // The path will change to contain each scholarship's id, which will directly affect the content + // shown in a subroute for this page. + + // As a result, this page likely only needs to contain the banner and the list of scholarships. + // The content will be shown within the right side, but only as a result of some subroutes, + // each of which is still considered a single page. + + view! { +
+
+
+ +
+ +
+
+
+
+ } +} + +#[component] +fn AdminApplicantsScholarshipList() -> impl IntoView { + use crate::common::ExpandableInfo; + + #[server] + async fn admin_get_provider_scholarships( + access_token: String, + ) -> Result, ServerFnError> { + use crate::pages::api::SCHOLARSHIPS_TABLE; + use crate::pages::api::tokens::validate_and_get_token_info; + + let claims = validate_and_get_token_info(access_token, "us-east-1_rvCU4Xy4j", "us-east-1").await?; + + // Get the information from the database. + let client = crate::utils::server::create_dynamo_client().await; + + let output = client + .scan() + .table_name(SCHOLARSHIPS_TABLE) + .send() + .await? + .items + .unwrap_or_default() + .into_iter() + .filter_map(|item| serde_dynamo::from_item(item).ok()) + .collect::>(); + + Ok(output) + } + + // Use access token for user identification + let auth = expect_context::(); + let access_token = + Memo::new(move |_| auth.with(|auth| auth.authenticated().map(|a| a.access_token()))); + + // We need to get the scholarships from the API. We don't need the whole scholarship, just the + // name and ID. + let trigger = Trigger::new(); + let scholarships_res = Resource::new( + move || (trigger.track(), access_token.get()), + async move |(_, access_token)| { + admin_get_provider_scholarships(access_token.unwrap_or_default()).await + }, + ); + + let navigate = use_navigate(); + + // This closure returns another closure that's using the correct scholarship ID. + // It avoids having to define it within the Transition component. It's wrapped in a Callback + // because this makes it Send + Sync. + let on_view_click = Callback::new(move |scholarship_id: String| { + let path = format!("/admin/applicants/{}", scholarship_id); + navigate(&*path, Default::default()); + }); + + view! { +
+

"Scholarships"

+ + {move || { + scholarships_res + .get() + .map(|items_res| { + let items = match items_res { + Ok(items) => items, + Err(e) => { + return view! { +
+ {format!("Couldn't get scholarships: {}", e.to_string())} +
+ } + .into_any(); + } + }; + + view! { + + + + } + .into_any() + }) + .collect_view() + }} +
+
+ } +} + +#[component] +fn ApplicantsScholarshipEntry( + #[prop()] item: ExpandableInfo, + #[prop(into)] on_view_click: Callback, +) -> impl IntoView { + // Store the item's subject. + let subject = StoredValue::new(item.subject); + let scholarship_name = Memo::new(move |_| { + item.data + .get("name") + .map(|name| name.as_string().ok().flatten()) + .flatten() + .unwrap_or("".to_string()) + }); + + let on_click = move |_| { + on_view_click.run(subject.get_value()); + }; + + view! { +
+
{scholarship_name}
+
+ "View Applicants" +
+
+ } +} + +#[component] +pub fn AdminApplicantsStudentList() -> impl IntoView { + use crate::pages::api::get_scholarship_info; + use crate::pages::api::students::admin_get_completed_students; + use leptos_router::hooks::use_params; + use leptos_router::params::Params; + + /// The parameters for the page that displays this component. This may be moved to the page-level + /// rather than the component-level. + #[derive(Params, PartialEq)] + struct StudentListParams { + scholarship_id: Option, + } + + // This is in charge of finding all student information and running the comparisons. + // It will then display all the eligible students. + + let params = use_params::(); + let auth = expect_context::(); + let access_token = + Memo::new(move |_| auth.with(|a| a.authenticated().map(|a| a.access_token()))); + + let scholarship_id = Memo::new(move |_| { + params + .read() + .as_ref() + .ok() + .and_then(|params| params.scholarship_id.clone()) + }); + + #[server] + async fn get_eligibility_info( + token: String, + scholarship_id: String, + ) -> Result< + ( + HashMap>, + ExpandableInfo, + Vec, + ), + ServerFnError, + > { + let (students, scholarships, requirements_list) = tokio::join!( + admin_get_completed_students(token), + get_scholarship_info(scholarship_id), + get_comparison_info() + ); + + Ok((students?, scholarships?, requirements_list?)) + } + + let resource = Resource::new( + move || (access_token.get(), scholarship_id.get()), + move |(access_token, scholarship_id)| async move { + let Some(token) = access_token else { + return Err(ServerFnError::new("Couldn't find access token")); + }; + let Some(scholarship_id) = scholarship_id else { + return Err(ServerFnError::new("Couldn't find scholarship ID")); + }; + + get_eligibility_info(token, scholarship_id).await + }, + ); + + view! { +
+ + {move || { + resource + .get() + .map(|result| { + match result { + Ok((students, scholarship, requirements_list)) => { + view! { +

"Eligible Students"

+
+ "Click on a student's name to view their information and download any requested files." +
+
+ "If your scholarship requires a transcript, please send an email to Sara Smith at " + + "ssmith@region15.org" + "." +
+ + } + .into_any() + } + Err(e) => { + view! { +
+ "Error while getting eligible students: "{e.to_string()} +
+ } + .into_any() + } + } + }) + }} +
+
+ } +} + +#[component] +fn AdminApplicantsStudentListView( + #[prop(into)] access_token: Signal>, + #[prop(into)] students: Signal>>, + #[prop(into)] scholarship: Signal, + #[prop(into)] requirements: Signal>, +) -> impl IntoView { + let eligible_students = RwSignal::new(HashMap::new()); + let error_msg = RwSignal::new(String::new()); + // let (current_student_id, set_current_student_id) = query_signal::("student_id"); + let current_student_id = RwSignal::new(None); + let navigate_student = Callback::new(move |id: String| { + let options = web_sys::ScrollToOptions::new(); + options.set_top(0.0); + options.set_behavior(ScrollBehavior::Smooth); + + if let Some(window) = web_sys::window() { + window.scroll_to_with_scroll_to_options(&options); + } + + current_student_id.set(Some(id)); + }); + let dialog_visible = + Signal::derive(move || !current_student_id.get().unwrap_or_default().is_empty()); + let on_dialog_close = Callback::new(move |_| { + current_student_id.set(None); + }); + + let fafsa_required = Memo::new(move |_| { + let required = scholarship + .get() + .data + .get("fafsa_required") + .map(|v| v.as_string().ok().flatten()) + .flatten() + .unwrap_or_default(); + + required == "Yes" + }); + + let essay_required = Memo::new(move |_| { + let prompt = scholarship + .get() + .data + .get("essay_prompt") + .map(|v| v.as_string().ok().flatten()) + .flatten() + .unwrap_or_default(); + + !prompt.is_empty() + }); + + let fafsa_resource = Resource::new( + move || (fafsa_required.get(), access_token.get()), + move |(fafsa_required, access_token)| async move { + // Only get this information if the FAFSA is required. Otherwise, return a blank result. + let Some(access_token) = access_token else { + return Err(ServerFnError::new("Couldn't find access token")); + }; + if !fafsa_required { + return Ok(HashMap::new()); + } + + admin_get_all_input_files( + access_token, + "financial_info".to_string(), + "fafsa".to_string(), + ) + .await + }, + ); + let essay_resource = Resource::new( + move || (essay_required.get(), access_token.get()), + move |(essay_required, access_token)| async move { + // Only get this information if the scholarship requested it. Otherwise, return a blank + // result. + let Some(access_token) = access_token else { + return Err(ServerFnError::new("Couldn't find access token")); + }; + if !essay_required { + return Ok(HashMap::new()); + } + + admin_get_all_input_files( + access_token, + "scholarship_essays".to_string(), + scholarship.get().subject, + ) + .await + }, + ); + + fn get_eligible_students( + students: HashMap>, + scholarship: ExpandableInfo, + requirements: Vec, + essay_required: bool, + fafsa_required: bool, + fafsa_list: HashMap>, + essay_list: HashMap>, + ) -> Result>, Error> { + // Get the full map of requirements. Each key contains a list of IDs. + let scholarship_requirements = scholarship + .data + .get("requirements") + .map(|val| val.as_map().ok()?) + .flatten() + .unwrap_or_default(); + + // Get the lists of requirements. Maps into string IDs and discards invalid ValueTypes. + let requirement_lists = scholarship_requirements + .into_iter() + .filter_map(|(_, list_val)| { + let list = list_val.as_list().ok().flatten()?; + let string_list = list + .into_iter() + .filter_map(|v| v.as_string().ok().flatten()) + .collect::>(); + + Some(string_list) + }) + .collect::>>(); + + // Resolve the requirement IDs. Discards all IDs that do not resolve. + let resolved_requirements = requirement_lists + .into_iter() + .map(|list| { + list.into_iter() + .filter_map(|id| { + requirements + .iter() + .cloned() + .find(|requirement| requirement.id == id) + }) + .collect::>() + }) + .collect::>>(); + + // Check student eligibility. These are all students that have completed the demographics + // forms. + let eligible_students = students + .into_iter() + .filter_map(|(id, student)| { + // The student needs to pass one requirement from each list. + // The easiest ones to check first are whether the scholarship requires a student + // essay and/or their fafsa information. If the student does not have these things, + // they are skipped. + + if fafsa_required && !fafsa_list.contains_key(&id) { + // Get the student files related to their fafsa and continue. Return false if not there. + debug_log!( + "Student with id {id} failed - FAFSA is required but was not found." + ); + return None; + } + + if essay_required && !essay_list.contains_key(&id) { + // Get the student's files related to their essays and continue. Return false if not there. + debug_log!( + "Student with id {id} failed - essay is required but was not found." + ); + return None; + } + + let result = resolved_requirements.iter().all(|list| { + if list.is_empty() { + return true; + } + list.iter().any(|requirement| { + let result = requirement.compare(&student).unwrap_or(false); + + if !result { + debug_log!( + "Student with id {id} failed - requirement with id {} failed.", + requirement.id + ); + } + + result + }) + }); + + if result { Some((id, student)) } else { None } + }) + .collect::>>(); + + // debug_log!("Total number of eligible students: {}", eligible_students.len()); + + Ok(eligible_students) + } + + Effect::new(move || { + // We have to wait until we've gotten fafsa/essay requirements. + debug_log!("Waiting on essay and FAFSA resources..."); + if let (Some(fafsa_list), Some(essay_list)) = (fafsa_resource.get(), essay_resource.get()) { + debug_log!( + "Running eligibility check on {} students", + students.get().len() + ); + let eligible_res = get_eligible_students( + students.get(), + scholarship.get(), + requirements.get(), + essay_required.get(), + fafsa_required.get(), + fafsa_list.unwrap_or_default(), + essay_list.unwrap_or_default(), + ); + + match eligible_res { + Ok(list) => { + debug_log!("Total number of eligible students: {}", list.len()); + eligible_students.set(list); + } + Err(e) => error_msg.set(format!("Failed to get eligible students list: {e}")), + } + } + }); + + // This is actually just a display of all the information from the students list. + // As defined in the ApplicantsStudentList component, this will already be wrapped in a + // flex container. + view! { + + +
"Error checking student eligibility: "{error_msg}
+
+
+
+
"Last Name"
+
"First Name"
+
+ +
{last_name}
+
{first_name}
+
+ } + } + /> +
+ } +} diff --git a/src/pages/admin/mod.rs b/src/pages/admin/mod.rs index 3bb847a..d090b82 100644 --- a/src/pages/admin/mod.rs +++ b/src/pages/admin/mod.rs @@ -3,9 +3,11 @@ mod home_page; mod provider_page; mod scholarship_page; mod utils_page; +mod applicants_list; pub use shell::*; pub use home_page::*; pub use provider_page::*; pub use scholarship_page::*; pub use utils_page::*; +pub use applicants_list::*; diff --git a/src/pages/admin/shell.rs b/src/pages/admin/shell.rs index 7bdca5c..d97f00b 100644 --- a/src/pages/admin/shell.rs +++ b/src/pages/admin/shell.rs @@ -25,6 +25,7 @@ pub fn AdminShell() -> impl IntoView { TabInfo::new("Providers", "providers", None), TabInfo::new("Scholarships", "scholarships", None), TabInfo::new("Utilities", "utilities", None), + TabInfo::new("Scholarship Applicants", "applicants", None), ] /> diff --git a/src/pages/api/files.rs b/src/pages/api/files.rs index aac22b9..fe8aad2 100644 --- a/src/pages/api/files.rs +++ b/src/pages/api/files.rs @@ -66,7 +66,7 @@ pub async fn upload_file(data: MultipartData) -> Result { return Err(ServerFnError::new("Missing file contents")); } - let user_claims = validate_and_get_token_info(access_token).await?; + let user_claims = validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?; let subject = user_claims.subject; let key = format!("{form_id}/{subject}/{input_name}/{file_name}"); @@ -157,7 +157,7 @@ pub async fn delete_file( ) -> Result { use imports::*; - let user_claims = validate_and_get_token_info(access_token).await?; + let user_claims = validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?; let subject = user_claims.subject; let entry_hk = format!("STUDENT#{subject}"); @@ -226,7 +226,7 @@ pub async fn list_files( ) -> Result, ServerFnError> { use imports::*; - let user_claims = validate_and_get_token_info(access_token).await?; + let user_claims = validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?; let subject = user_claims.subject; let entry_hk = format!("STUDENT#{subject}"); diff --git a/src/pages/api/students.rs b/src/pages/api/students.rs index f83e76c..1b1dc3d 100644 --- a/src/pages/api/students.rs +++ b/src/pages/api/students.rs @@ -137,24 +137,41 @@ pub async fn get_all_student_data( } #[server] -pub async fn get_completed_students( +pub async fn provider_get_completed_students( access_token: String, ) -> Result>, ServerFnError> { use imports::*; + let claims = validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?; + if !claims.groups.contains(&"ScholarshipProviders".to_string()) { + return Err(ServerFnError::new( + "User is not in the ScholarshipProviders group", + )); + } + + get_completed_students().await +} + +#[server] +pub async fn admin_get_completed_students( + access_token: String, +) -> Result>, ServerFnError> { + use imports::*; + + let _ = validate_and_get_token_info(access_token, "us-east-1_rvCU4Xy4j", "us-east-1").await?; + + get_completed_students().await +} + +#[server] +async fn get_completed_students() -> Result>, ServerFnError> { + use imports::*; + // We want to get all student information. The requirements are that the students have completed // the demographics form - everything else may bed from this. // The easiest way is to get all the information and filter on this side, instead of bookkeeping // on the database's side. - // We want to verify the access token first, and make sure that the user has the correct access. - let claims = validate_and_get_token_info(access_token).await?; - if !claims.groups.contains(&"ScholarshipProviders".to_string()) { - return Err(ServerFnError::new( - "User is not in the ScholarshipProviders group", - )); - } - let client = create_dynamo_client().await; let response = client @@ -229,7 +246,7 @@ pub async fn get_file_by_key( ) -> Result, ServerFnError> { use imports::*; - let user_claims = validate_and_get_token_info(access_token).await?; + let user_claims = validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?; // Check if the user's subject is contained in the file_key (since all files are keyed by the // user's subject) @@ -279,7 +296,7 @@ pub async fn get_student_files( ) -> Result<(String, Vec), ServerFnError> { use imports::*; - let claims = validate_and_get_token_info(access_token).await?; + let claims = validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?; // Check if the user has permission to get these files. if !claims.groups.contains(&"ScholarshipProviders".to_string()) { @@ -420,19 +437,12 @@ pub async fn get_student_files( /// /// This is only usable by users with provider-level access. #[server] -pub async fn get_all_input_files( - access_token: String, +async fn get_all_input_files( form_name: String, input_name: String, ) -> Result>, ServerFnError> { use imports::*; - let claims = validate_and_get_token_info(access_token).await?; - - if !claims.groups.contains(&"ScholarshipProviders".to_string()) { - return Err(ServerFnError::new("Access denied: user is not a provider")); - } - let client = create_dynamo_client().await; let res = client @@ -473,3 +483,32 @@ pub async fn get_all_input_files( Ok(result_map) } + +#[server] +pub async fn admin_get_all_input_files( + access_token: String, + form_name: String, + input_name: String, +) -> Result>, ServerFnError> { + use imports::*; + + let _ = validate_and_get_token_info(access_token, "us-east-1_rvCU4Xy4j", "us-east-1").await?; + + get_all_input_files(form_name, input_name).await +} + +#[server] +pub async fn provider_get_all_input_files( + access_token: String, + form_name: String, + input_name: String, +) -> Result>, ServerFnError> { + use imports::*; + + let claims = validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?; + if !claims.groups.contains(&"ScholarshipProviders".to_string()) { + return Err(ServerFnError::new("Access denied: user is not a provider")); + } + + get_all_input_files(form_name, input_name).await +} diff --git a/src/pages/api/tokens.rs b/src/pages/api/tokens.rs index e71db7e..ea45aff 100644 --- a/src/pages/api/tokens.rs +++ b/src/pages/api/tokens.rs @@ -17,10 +17,19 @@ pub async fn fetch_jwks(user_pool_id: String, region: String) -> Result Result { +pub async fn validate_and_get_token_info( + token: String, + pool_id: impl Into, + pool_region: impl Into +) -> Result { use jsonwebtoken::{Algorithm, Validation, decode}; - let jwks = fetch_jwks("us-east-1_Lfjuy5zaM".into(), "us-east-1".into()).await?; + let pool_id = pool_id.into(); + + leptos::logging::debug_log!("Validating token using group {}", pool_id); + + // let jwks = fetch_jwks("us-east-1_Lfjuy5zaM".into(), "us-east-1".into()).await?; + let jwks = fetch_jwks(pool_id, pool_region.into()).await?; let validation = Validation::new(Algorithm::RS256); // validation.set_audience(&["scholarships-rs"]); diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index c13530c..cc83dd6 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -1,11 +1,8 @@ -use crate::common::{ - ComparisonData, ExpandableInfo, SchemaContainerStyle, SchemaHeaderStyle, SchemaNode, - SchemaType, ValueType, -}; +use crate::common::{ComparisonData, ExpandableInfo, SchemaContainerStyle, SchemaHeaderStyle, SchemaNode, SchemaType, ValueType}; use crate::components::{ActionButton, Banner, DataDisplay, Loading}; use crate::pages::UnauthenticatedPage; use crate::pages::api::get_comparison_info; -use crate::pages::api::students::{GetStudentFiles, get_all_input_files, get_student_data}; +use crate::pages::api::students::{GetStudentFiles, provider_get_all_input_files, get_student_data}; use base64::Engine; use leptos::ev::{Event, MouseEvent}; use leptos::html::Dialog; @@ -71,12 +68,12 @@ fn ApplicantsScholarshipList() -> impl IntoView { #[server] async fn get_provider_scholarships( - access_token: String, + access_token: String ) -> Result, ServerFnError> { use crate::pages::api::SCHOLARSHIPS_TABLE; use crate::pages::api::tokens::validate_and_get_token_info; - let claims = validate_and_get_token_info(access_token).await?; + let claims = validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM".to_string(), "us-east-1".to_string()).await?; // Get the information from the database. let client = crate::utils::server::create_dynamo_client().await; @@ -110,8 +107,10 @@ fn ApplicantsScholarshipList() -> impl IntoView { let trigger = Trigger::new(); let scholarships_res = Resource::new( move || (trigger.track(), access_token.get()), - async move |(_, access_token)| { - get_provider_scholarships(access_token.unwrap_or_default()).await + move |(_, access_token)| async move { + get_provider_scholarships( + access_token.unwrap_or_default() + ).await }, ); @@ -201,7 +200,7 @@ fn ApplicantsScholarshipEntry( #[component] pub fn ApplicantsStudentList() -> impl IntoView { use crate::pages::api::get_scholarship_info; - use crate::pages::api::students::get_completed_students; + use crate::pages::api::students::provider_get_completed_students; use leptos_router::hooks::use_params; use leptos_router::params::Params; @@ -241,7 +240,7 @@ pub fn ApplicantsStudentList() -> impl IntoView { ServerFnError, > { let (students, scholarships, requirements_list) = tokio::join!( - get_completed_students(token), + provider_get_completed_students(token), get_scholarship_info(scholarship_id), get_comparison_info() ); @@ -374,7 +373,7 @@ fn ApplicantsStudentListView( return Ok(HashMap::new()); } - get_all_input_files( + provider_get_all_input_files( access_token, "financial_info".to_string(), "fafsa".to_string(), @@ -394,7 +393,7 @@ fn ApplicantsStudentListView( return Ok(HashMap::new()); } - get_all_input_files( + provider_get_all_input_files( access_token, "scholarship_essays".to_string(), scholarship.get().subject, @@ -592,7 +591,7 @@ fn ApplicantsStudentListView( } #[component] -fn StudentInformationDialog( +pub fn StudentInformationDialog( #[prop(into)] scholarship_id: Signal, #[prop(into)] student_id: Signal>, #[prop(into)] visible: Signal, @@ -1287,7 +1286,10 @@ fn StudentInformationDialog( }) })} {move || Suspend::new(async move { - get_student_data(student_id.get().unwrap_or_default(), "family".to_string()) + get_student_data( + student_id.get().unwrap_or_default(), + "family".to_string() + ) .await .map(|student_info| { view! { -- 2.54.0 From 8282ba4302a7abaa28b1156a77e784fd5049bda3 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 27 Apr 2026 02:52:44 -0400 Subject: [PATCH 2/4] feat: add pdf gen capability --- Dockerfile | 3 + pdf_template.typ | 141 ++++++++++++++++++++++++++++ src/pages/admin/utils_page.rs | 32 +++++++ src/pages/api/students.rs | 154 +++++++++++++++++++++++++++++++ src/pages/provider_applicants.rs | 56 +++++++---- 5 files changed, 370 insertions(+), 16 deletions(-) create mode 100644 pdf_template.typ diff --git a/Dockerfile b/Dockerfile index 77235d4..d298018 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,9 @@ RUN apt-get update -y \ && apt-get clean -y \ && rm -rf /var/lib/apt/lists/* +RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash +RUN cargo binstall typst-cli -y + # Remember to replace project_name with the actual project name COPY --from=builder /app/target/release/scholarships-rs /app/ diff --git a/pdf_template.typ b/pdf_template.typ new file mode 100644 index 0000000..1c2a5dd --- /dev/null +++ b/pdf_template.typ @@ -0,0 +1,141 @@ +#set page(margin: 40pt) +#set block(breakable: false) + +#let map_block(header, val) = { + block( + width: 100%, + fill: rgb(200, 0, 0), + radius: 4pt, + below: 0pt, + above: 4pt, + inset: 2pt, + [ + #show heading: set text(white) + #show heading: set text(size: 15pt) + #stack(dir: ttb, + block( + inset: 7pt, + align(horizon)[= #header] + ), + align(center)[#block( + width: 100%, + fill: white, + inset: 8pt, + radius: 3pt, + align(left)[#val] + )], + ) + ] + ) +} + +#let info_line(subject, key, dict: student) = [ + + *#subject* #dict.at(key, default: "N/A") +] + +#let info_line_list(subject, key, dict: student) = [ + + *#subject* #dict.at(key, default: "N/A").join(", ") +] + +#show heading: set align(center) +#show heading: set text(size: 20pt) +#v(10cm) += #student.first_name #student.last_name +#show heading: set text(size: 17pt) +== Region 15 General Scholarship Application +#pagebreak() + +#show heading: set block(below: 20pt, above: 30pt) +#show heading: set text(size: 20pt) += Student Demographic Information + +#info_line("First Name:", "first_name") +#info_line("Last Name:", "last_name") +#info_line("Date of Birth:", "dob") +#info_line("Gender:", "gender") +#info_line("Phone Number:", "phone_number") +#info_line("Email:","email") +#info_line("Street Address:", "street_address") +#info_line("Town:", "town") + += Academic Information + +#info_line("Unweighted GPA:", "unweighted_gpa") +#info_line("Weighted GPA:", "weighted_gpa") +#info_line("Highest SAT Score:", "sat_score") +#info_line("Highest ACT Score:", "act_score") +#info_line("Academic Honors:", "academic_honors") + += Sports Participation +#if student.at("sports_participation", default: ()).len() == 0 [ + Student did not enter any sports participation. +] else { + for pair in student.sports_participation { + map_block(pair.at("sport_name"), [ + #info_line("Special Achievements:", "achievements", dict: pair) + #info_line_list("Grades Participated:", "grades", dict: pair) + ]) + } +} + += Extracurricular Activities +#if student.at("extracurricular", default: ()).len() == 0 [ + Student did not enter any extracurricular activities. +] else { + for pair in student.extracurricular { + map_block(pair.at("activity_name"), [ + #info_line("Number of hours per week:", "num_hours", dict: pair) + #info_line("Number of weeks participated:", "num_weeks", dict: pair) + #info_line("Special Involvement:", "special_involvement", dict: pair) + ]) + } +} + += Work Experience +#if student.at("work_experience", default: ()).len() == 0 [ + Student did not enter any work experience. +] else { + for pair in student.work_experience { + map_block(pair.at("employer"), [ + #info_line("Job Title:", "job_title", dict: pair) + #info_line("Start Date:", "start_date", dict: pair) + #info_line("End Date:", "end_date", dict: pair) + #info_line("Number of hours per week:", "num_hours", dict: pair) + ]) + } +} + += University Information + +#info_line("University Name:", "college_name") +#info_line("University Street Address:", "college_city") +#info_line("University State:", "college_state") +#info_line("University ZIP:", "college_zip") +#info_line("Has the student received an acceptance:", "college_acceptance") +#info_line("Student's chosen major:", "major") +#info_line("Student's intended career:", "intended_career") + += Family Information + +#info_line("Total number of children in student's family:", "num_children") +#info_line("Total number of children currently attending college:", "num_children_college") +#info_line("Parent/Guardian 1 Name:", "parent_one_name") +#info_line("Parent/Guardian 1 Relationship:", "parent_one_relationship") +#info_line("Parent/Guardian 1 Occupation:", "parent_one_occupation") +#info_line("Parent/Guardian 1 Employer:", "parent_one_employer") +#info_line("Parent/Guardian 2 Name:", "parent_two_name") +#info_line("Parent/Guardian 2 Relationship:", "parent_two_relationship") +#info_line("Parent/Guardian 2 Occupation:", "parent_two_occupation") +#info_line("Parent/Guardian 2 Employer:", "parent_two_employer") + += Extra Eligibility Information + +#info_line("Did the student attend BAS:", "attend_bas") +#info_line("Is the student a member of Midd-South Catholic Church:", "middsouth_church") +#info_line("Does the student have a family member who has served in the US Military:", "family_military_service") +#info_line("Has the student participated in Pomperaug Youth Baseball:", "youth_baseball") +#info_line("Has the student participated in the Panthers Aquatic Club:", "aquatic_club") +#info_line("Does the student have a family member in the Region 15 PEA:", "pea_member") +#info_line("Has the student participated in the PHS Music Program:", "music_program") diff --git a/src/pages/admin/utils_page.rs b/src/pages/admin/utils_page.rs index 202f770..a1e9966 100644 --- a/src/pages/admin/utils_page.rs +++ b/src/pages/admin/utils_page.rs @@ -7,6 +7,7 @@ use leptos::prelude::*; use leptos::task::spawn_local; use leptos::wasm_bindgen::JsCast; use leptos::web_sys::{Blob, HtmlAnchorElement, Url, js_sys}; +use crate::pages::api::students::{get_student_info_json, get_student_pdf}; fn get_important_dates() -> Vec { vec![ @@ -100,6 +101,36 @@ pub fn AdminUtilsPage() -> impl IntoView { }); }; + let on_click_json = move |_| { + spawn_local(async move { + let (file_name, file_bytes) = get_student_pdf("54d82438-c051-709d-30f7-c60e97f5112a".to_string()) + .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(&file_name); + document.body().unwrap().append_child(&a).unwrap(); + a.click(); + + document.body().unwrap().remove_child(&a).unwrap(); + Url::revoke_object_url(&blob_url).unwrap(); + }); + }; + view! {
@@ -109,6 +140,7 @@ pub fn AdminUtilsPage() -> impl IntoView { "Create Comparisons" "Create Dates" "Scholarship Export" + "Get student JSON"
}.into_any() diff --git a/src/pages/api/students.rs b/src/pages/api/students.rs index 1b1dc3d..c9089d6 100644 --- a/src/pages/api/students.rs +++ b/src/pages/api/students.rs @@ -2,6 +2,7 @@ use leptos::prelude::*; use leptos::server_fn::codec::Json; use std::collections::HashMap; use std::io::Write; +use std::process::Stdio; #[cfg(feature = "ssr")] mod imports { @@ -14,8 +15,12 @@ mod imports { pub use leptos::logging::{debug_log, error}; pub use std::collections::HashMap; pub use zip::write::SimpleFileOptions; + pub use leptos::serde_json; + pub use std::process::Command; } +static PDF_TEMPLATE: &str = include_str!("../../../pdf_template.typ"); + #[server(input = Json)] pub async fn put_student_data( subject: String, @@ -512,3 +517,152 @@ pub async fn provider_get_all_input_files( get_all_input_files(form_name, input_name).await } + +#[server] +pub async fn get_student_pdf( + student_id: String, +) -> Result<(String, Vec), ServerFnError> { + use imports::*; + + // NOTE: this API requires that the server has the typst-cli available ON PATH. + + let student_info_str = get_student_info_json(student_id.clone()).await?; + let student_info = get_student_data(student_id, "DEMOGRAPHICS".to_string()).await?; + + let first_name = student_info.get("first_name") + .map(|v| v.to_string()) + .unwrap_or_default(); + + let last_name = student_info.get("last_name") + .map(|v| v.to_string()) + .unwrap_or_default(); + + let typst_string = student_info_str + .replace("{", "(") + .replace("}", ")") + .replace("[", "(") + .replace("]", ")") + // Ensures trailing commas for lists of dictionaries + .replace("))", "),)"); + + debug_log!("New string: {}", typst_string); + + let pdf_string = format!("#let student = {typst_string}\n{PDF_TEMPLATE}"); + debug_log!("PDF string: {}", pdf_string); + let mut child = Command::new("typst") + .arg("compile") + .arg("-") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + + { + let stdin = child.stdin.as_mut().unwrap(); + stdin.write_all(pdf_string.as_bytes())?; + stdin.flush()?; + } + + let output = child.wait_with_output()?; + + if !output.status.success() { + let msg = String::from_utf8_lossy(&output.stderr).to_string(); + error!("Failed to generate PDF: {}", msg); + Err(ServerFnError::new(format!("Failed to generate PDF: {msg}"))) + } else { + let bytes = output.stdout; + let file_name = format!("{first_name}{last_name}_Application.pdf"); + Ok((file_name, bytes)) + } +} + +#[server] +pub async fn get_student_info_json( + student_id: String, +) -> Result { + use imports::*; + + let client = create_dynamo_client().await; + + let response = client + .query() + .table_name(MAIN_TABLE_NAME) + .expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}"))) + .key_condition_expression("HK = :hk") + .send() + .await + .map_err(|e| { + let msg = e.message().unwrap_or("Unknown error occurred"); + error!("{}", msg); + ServerFnError::new(msg) + })?; + + let work_exp = client + .query() + .table_name(MAIN_TABLE_NAME) + .expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}"))) + .expression_attribute_values(":sk", AttributeValue::S("WORKEXP".to_string())) + .key_condition_expression("HK = :hk AND SK = :sk") + .send() + .await + .map_err(|e| { + let msg = e.message().unwrap_or("Unknown error occurred"); + error!("{}", msg); + ServerFnError::new(msg) + }) + .map(|output| { + let items = output.items.unwrap_or_default(); + debug_log!("Number of items: {}", items.len()); + items.into_iter() + .next() + .unwrap_or_default() + .get("extracurricular") + .cloned() + .unwrap_or(AttributeValue::S("".to_string())) + })?; + + let extracurricular = client + .query() + .table_name(MAIN_TABLE_NAME) + .expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}"))) + .expression_attribute_values(":sk", AttributeValue::S("DEMOGRAPHICS".to_string())) + .key_condition_expression("HK = :hk AND SK = :sk") + .send() + .await + .map_err(|e| { + let msg = e.message().unwrap_or("Unknown error occurred"); + error!("{}", msg); + ServerFnError::new(msg) + }) + .map(|output| { + let items = output.items.unwrap_or_default(); + debug_log!("Number of items: {}", items.len()); + items.into_iter() + .next() + .unwrap_or_default() + .get("extracurricular") + .cloned() + .unwrap_or(AttributeValue::S("".to_string())) + })?; + + let Some(items) = response.items else { + return Err(ServerFnError::new("Failed to find student information.")); + }; + + // Join items together into a single object. + let mut data = items.into_iter() + .fold(HashMap::new(), |mut map, item| { + item.into_iter() + .for_each(|(k, v)| { map.insert(k, v); }); + map + }); + + data.insert("work_experience".to_string(), work_exp); + data.insert("extracurricular".to_string(), extracurricular); + + let json_value: serde_json::Value = serde_dynamo::from_item(data)?; + let json_string = serde_json::to_string(&json_value)?; + println!("{}", json_string); + + Ok(json_string) +} diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index cc83dd6..1f1038b 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -2,7 +2,7 @@ use crate::components::{ActionButton, Banner, DataDisplay, Loading}; use crate::pages::UnauthenticatedPage; use crate::pages::api::get_comparison_info; -use crate::pages::api::students::{GetStudentFiles, provider_get_all_input_files, get_student_data}; +use crate::pages::api::students::{GetStudentFiles, provider_get_all_input_files, get_student_data, GetStudentPdf}; use base64::Engine; use leptos::ev::{Event, MouseEvent}; use leptos::html::Dialog; @@ -1120,7 +1120,10 @@ pub fn StudentInformationDialog( // scholarship, meaning their essays and/or FAFSA by request. let get_student_files = ServerAction::::new(); - let get_buttons_disabled = get_student_files.pending(); + let get_student_pdf = ServerAction::::new(); + let get_buttons_disabled = Memo::new(move |_| { + get_student_files.pending().get() && get_student_pdf.pending().get() + }); let on_click_fafsa = move |_| { get_student_files.dispatch(GetStudentFiles { access_token: access_token.get().unwrap_or_default(), @@ -1142,23 +1145,41 @@ pub fn StudentInformationDialog( }); }; + let on_click_pdf = move |_| { + debug_log!("Getting student PDF"); + get_student_pdf.dispatch(GetStudentPdf { + student_id: student_id.get().unwrap_or_default(), + }); + }; + + fn open_file(file_name: String, file: Vec) { + let base64 = base64::engine::general_purpose::STANDARD.encode(file); + let data_url = format!("data:application/zip;base64,{}", base64); + // Create a base64 link and open it. + let a: HtmlAnchorElement = document() + .create_element("a") + .expect("Failed to create anchor element") + .dyn_into() + .unwrap(); + + a.set_download(&*file_name); + a.set_href(&data_url); + document().body().unwrap().append_child(&a).unwrap(); + a.click(); + a.remove(); + } + Effect::new(move || { if let Some(Ok((file_name, file))) = get_student_files.value().get() { - let base64 = base64::engine::general_purpose::STANDARD.encode(file); - let data_url = format!("data:application/zip;base64,{}", base64); - // Create a base64 link and open it. - let a: HtmlAnchorElement = document() - .create_element("a") - .expect("Failed to create anchor element") - .dyn_into() - .unwrap(); - - a.set_download(&*file_name); - a.set_href(&data_url); - document().body().unwrap().append_child(&a).unwrap(); - a.click(); - a.remove(); + open_file(file_name, file); + get_student_files.clear(); + } + }); + // Copy and paste is bad, but it's a little short on time. + Effect::new(move || { + if let Some(Ok((file_name, file))) = get_student_pdf.value().get() { + open_file(file_name, file); get_student_files.clear(); } }); @@ -1192,6 +1213,9 @@ pub fn StudentInformationDialog( "Download Essay File(s)" + + "Download Student Application" +
{move || Suspend::new(async move { get_student_data( -- 2.54.0 From 5b44bfb914f854d03398ab45eb1e10d42cd7d6d2 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 27 Apr 2026 02:53:17 -0400 Subject: [PATCH 3/4] chore: style admin applicants list layout --- src/pages/admin/applicants_list.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/pages/admin/applicants_list.rs b/src/pages/admin/applicants_list.rs index 9a906d5..9b2a33c 100644 --- a/src/pages/admin/applicants_list.rs +++ b/src/pages/admin/applicants_list.rs @@ -44,16 +44,14 @@ pub fn AdminApplicantsPageShell() -> impl IntoView { // each of which is still considered a single page. view! { -
-
-
- -
- -
+
+
+ +
+
-
+
} } -- 2.54.0 From e9a42aa0fa744a311169ba10e8ff35619b062611 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 27 Apr 2026 03:29:58 -0400 Subject: [PATCH 4/4] fix: update dockerfile to build correctly --- Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index d298018..9d628cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,14 +27,15 @@ RUN cargo binstall fnm -y \ # Start second phase of build - install ca-certs and copy all build outputs from previous step into a # Debian image in the /app directory. -FROM debian:trixie-slim AS runtime +FROM rust:1.95-slim-trixie AS runtime WORKDIR /app RUN apt-get update -y \ - && apt-get install -y --no-install-recommends openssl ca-certificates \ + && apt-get install -y --no-install-recommends openssl ca-certificates curl \ && apt-get autoremove -y \ && apt-get clean -y \ && rm -rf /var/lib/apt/lists/* +# Cargo, then binstall, then typst installation. RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash RUN cargo binstall typst-cli -y -- 2.54.0