Compare commits

...
50 Commits
Author SHA1 Message Date
Lucas Vas 39178d05a5 Merge pull request #77 from R15-Software-Dev/feat/applicants-page
Create provider-side applicant forms
2026-04-13 02:35:26 -04:00
alphafetus 4b398c8445 fix: remove unnecessary module import
File was removed in previous commit, git somehow broke.
2026-04-13 02:15:07 -04:00
Lucas Vas 6294b114df Update mod.rs 2026-04-13 01:41:50 -04:00
Lucas Vas 48f711a84d Merge branch 'main' into feat/applicants-page 2026-04-13 01:33:28 -04:00
alphafetus 21c0df0ded feat: implement the rest of the required forms 2026-04-13 01:31:03 -04:00
alphafetus eae8d2a26a feat: implement new data display component 2026-04-13 00:20:49 -04:00
alphafetus 07c1679e1a feat: style list of eligible students 2026-04-10 17:25:56 -04:00
alphafetus 2b255ce968 feat: changed zipped file names and disabled download file names 2026-04-10 13:44:54 -04:00
alphafetus 5cda2f8070 feat: add extra styling to student dialog 2026-04-10 00:36:07 -04:00
alphafetus f8d399f9ae feat: create file download capability 2026-04-09 23:49:31 -04:00
alphafetus c5b11481b3 feat: make scholarship filtering work on fafsa and essay requirements 2026-04-09 20:53:47 -04:00
alphafetus 3cf5e6342b feat: create closed form page and close all student forms 2026-04-09 11:08:28 -04:00
alphafetus 93a4b54d42 feat: create file name apis 2026-04-09 10:40:14 -04:00
alphafetus c6386e54fc feat: create get_student_file server function 2026-04-08 14:37:30 -04:00
alphafetus 469f192feb feat: implement basic structure of provider applicants page 2026-04-08 13:55:07 -04:00
alphafetus 3baee8cac7 feat: add applicants list to the routes 2026-04-01 13:51:25 -04:00
alphafetus 17e339ea00 feat: add cognito:groups field 2026-04-01 13:50:49 -04:00
alphafetus 152c01124b feat: improve error logging when JSON does not parse 2026-04-01 13:50:31 -04:00
alphafetus f8502ba781 Merge remote-tracking branch 'origin/feat/applicants-page' into feat/applicants-page 2026-03-31 07:14:56 -04:00
alphafetus 23c0029317 style: rustfmt run 2026-03-31 07:14:44 -04:00
alphafetus 8a44513a04 Merge branch 'main' into feat/applicants-page
# Conflicts:
#	src/app.rs
#	src/pages/student/eligibility_page.rs
2026-03-31 07:14:13 -04:00
alphafetus 2db09af903 Merge branch 'main' into feat/applicants-page
# Conflicts:
#	src/pages/student/eligibility_page.rs
2026-03-27 14:28:28 -04:00
Lucas Vas 467f8c9020 Merge pull request #76 from R15-Software-Dev/chore/api-err-return
Make student eligibility return an error
2026-03-25 13:42:25 -04:00
alphafetus 909f33b044 chore: make student eligibility return an error on missing access token 2026-03-25 13:41:10 -04:00
Lucas Vas 44450f407c Merge pull request #75 from R15-Software-Dev/chore/close-providers
Close provider forms
2026-03-25 13:31:14 -04:00
Lucas Vas 70a86f4545 Merge pull request #74 from R15-Software-Dev/feat/file-input-errors
Create error message display for file inputs
2026-03-25 13:28:23 -04:00
alphafetus e6409cc423 chore: close provider forms 2026-03-25 13:20:58 -04:00
alphafetus 4e3161a05f feat: create error message display for file inputs 2026-03-25 13:17:43 -04:00
Lucas Vas 51dfa013df Merge pull request #73 from R15-Software-Dev/fix/blank-id-requests
Fix for blank IDs in submissions
2026-03-25 12:58:55 -04:00
alphafetus 1d41b6022e fix: hopeful fix for blank IDs in submissions 2026-03-25 12:39:50 -04:00
Lucas Vas f9d5b25436 Merge pull request #72 from R15-Software-Dev/fix/filtering-bugs
Check requirement return value on Ok variant
2026-03-23 12:05:22 -04:00
alphafetus 891e324186 fix: check requirement return value on Ok variant 2026-03-23 11:47:06 -04:00
Lucas Vas b502c2f96d Merge pull request #71 from R15-Software-Dev/fix/comparison-filtering
Fix comparison logic for filtering
2026-03-19 13:49:06 -04:00
alphafetus 681da8c326 fix: use correct comparison logic
Was originally short-circuiting on empty lists of requirements.
2026-03-19 13:37:11 -04:00
alphafetus 05b865c608 feat: implement applicant page shell 2026-03-18 13:48:26 -04:00
Lucas Vas 628b607074 Merge pull request #70 from R15-Software-Dev/fix/api-errors
Significantly improve logging for api requests
2026-03-18 12:30:19 -04:00
alphafetus 97be249826 fix: import ProvideErrorMetadata correctly 2026-03-18 12:16:44 -04:00
alphafetus e1bd472a36 feat: significantly improve logging for api requests 2026-03-18 12:08:41 -04:00
alphafetus 2e134fb693 temp: begin implementation of applicants page 2026-03-18 12:01:06 -04:00
alphafetus db99f39716 style: rustfmt run 2026-03-18 11:39:07 -04:00
Lucas Vas dd19631e33 Merge pull request #69 from R15-Software-Dev/fix/comparison-changes
Small comparison changes
2026-03-18 10:38:26 -04:00
alphafetus a0b206a554 fix: do not display scholarships that are not completed 2026-03-12 10:41:32 -04:00
alphafetus e9b0452d38 feat: improve error messages for client side comparison check 2026-03-11 14:24:25 -04:00
Lucas Vas 448e8bbb38 Merge pull request #68 from R15-Software-Dev/fix/comparison-changes
Comparison and sports changes
2026-03-11 12:49:53 -04:00
alphafetus ae7a17ad92 chore: add volleyball option 2026-03-11 11:54:34 -04:00
alphafetus 750059e9ad fix: attended_bas checks correct data member 2026-03-11 11:53:50 -04:00
alphafetus 5a371ae574 chore: temporarily reopen provider forms (for real this time) 2026-03-11 11:53:05 -04:00
alphafetus d7f44463c6 chore: temporarily reopen provider forms 2026-03-11 11:33:30 -04:00
alphafetus 5872002538 feat: improve error messages in files api 2026-03-11 11:28:52 -04:00
alphafetus 957fc0c5eb chore: change title of form 2026-03-09 12:16:41 -04:00
27 changed files with 2615 additions and 634 deletions

No files matched your search

Generated
+202
View File
@@ -2,6 +2,23 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -739,6 +756,15 @@ dependencies = [
"either",
]
[[package]]
name = "bzip2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
dependencies = [
"libbz2-rs-sys",
]
[[package]]
name = "camino"
version = "1.2.2"
@@ -789,6 +815,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "cmake"
version = "0.1.57"
@@ -895,6 +931,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "convert_case"
version = "0.6.0"
@@ -1173,6 +1215,12 @@ dependencies = [
"syn",
]
[[package]]
name = "deflate64"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
[[package]]
name = "der"
version = "0.6.1"
@@ -1359,6 +1407,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -2237,6 +2295,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "interpolator"
version = "0.5.0"
@@ -2683,6 +2750,12 @@ dependencies = [
"tachys",
]
[[package]]
name = "libbz2-rs-sys"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
[[package]]
name = "libc"
version = "0.2.182"
@@ -2725,6 +2798,15 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rust2"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae"
dependencies = [
"sha2",
]
[[package]]
name = "macros"
version = "0.1.0"
@@ -2795,6 +2877,16 @@ dependencies = [
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.1.1"
@@ -2958,6 +3050,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "pem"
version = "3.0.6"
@@ -3016,6 +3118,12 @@ dependencies = [
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -3031,6 +3139,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppmd-rust"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -3621,6 +3735,7 @@ dependencies = [
"chrono",
"console_error_panic_hook",
"csv",
"futures",
"icondata",
"indexmap",
"jsonwebtoken 10.3.0",
@@ -3640,6 +3755,7 @@ dependencies = [
"traits",
"uuid",
"wasm-bindgen",
"zip",
]
[[package]]
@@ -3931,6 +4047,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simple_asn1"
version = "0.6.4"
@@ -4177,6 +4299,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"js-sys",
"num-conv",
"powerfmt",
"serde_core",
@@ -4464,6 +4587,12 @@ dependencies = [
"syn",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typenum"
version = "1.19.0"
@@ -5311,8 +5440,81 @@ dependencies = [
"syn",
]
[[package]]
name = "zip"
version = "8.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59"
dependencies = [
"aes",
"bzip2",
"constant_time_eq",
"crc32fast",
"deflate64",
"flate2",
"getrandom 0.4.1",
"hmac",
"indexmap",
"lzma-rust2",
"memchr",
"pbkdf2",
"ppmd-rust",
"sha1",
"time",
"typed-path",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zlib-rs"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
+4 -1
View File
@@ -25,7 +25,6 @@ leptos_oidc = "0.9.0"
base64 = "0.22"
chrono = "0.4"
leptos_animate = "0.1"
macros = { path = "./macros" }
traits = { path = "./traits" }
icondata = { version = "0.7.0", features = ["font-awesome"] }
@@ -35,6 +34,8 @@ csv = "1.4.0"
leptos-use = { version = "0.18.3", features = ["use_drop_zone"] }
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"], optional = true }
reqwest = { version = "0.13.2", features = ["json"] }
zip = { version = "8.5.1", optional = true }
futures = { version = "0.3.32", optional = true }
[features]
default = ["ssr"]
@@ -52,6 +53,8 @@ ssr = [
"dep:aws-config",
"dep:serde_dynamo",
"dep:jsonwebtoken",
"dep:zip",
"dep:futures",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
+24 -29
View File
@@ -2,10 +2,11 @@ use crate::components::ToastList;
use crate::components::login::ProviderLoginContext;
use crate::pages::{
AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminShell, AdminUtilsPage,
ApplicantsPageFallback, AuthCallbackPage, LoanerBorrowForm, LoanerFallback, LoanerReturnForm,
LoanerShell, ProviderContactPage, ProviderPortal,
ApplicantsPageFallback, ApplicantsStudentList, AuthCallbackPage, LoanerBorrowForm,
LoanerFallback, LoanerReturnForm, LoanerShell, ProviderContactPage, ProviderPortal,
ScholarshipInfoPage,
};
use crate::pages::{FormClosedPage, student};
use crate::pages::{ApplicantsPageShell, student};
use leptos::prelude::*;
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
use leptos_oidc::AuthSignal;
@@ -86,14 +87,17 @@ fn ProviderRoutes() -> impl MatchNestedRoutes + Clone {
view! {
<ParentRoute path=path!("/providers") view=ProviderLoginContext>
<Route path=path!("") view=ProviderPortal />
<Route path=path!("/applicants") view=ApplicantsPageFallback />
<ParentRoute path=path!("/applicants") view=ApplicantsPageShell>
<Route path=path!("") view=ApplicantsPageFallback />
<Route path=path!("/:scholarship_id") view=ApplicantsStudentList />
</ParentRoute>
<Route path=path!("/callback") view=AuthCallbackPage />
<Route path=path!("/profile") view=ProviderContactPage />
<Route path=path!("/scholarships") view=FormClosedPage />
// <ParentRoute path=path!("/scholarships") view=FormClosedPage>
// <Route path=path!(":id") view=FormClosedPage />
// <Route path=path!("") view=FormClosedPage />
// </ParentRoute>
// <Route path=path!("/scholarships") view=FormClosedPage />
<ParentRoute path=path!("/scholarships") view=ScholarshipInfoPage>
<Route path=path!(":id") view=ScholarshipInfoPage />
<Route path=path!("") view=ScholarshipInfoPage />
</ParentRoute>
</ParentRoute>
}
.into_inner()
@@ -134,26 +138,17 @@ fn StudentRoutes() -> impl MatchNestedRoutes + Clone {
<ParentRoute path=path!("students") view=student::StudentShell>
<Route path=path!("callback") view=AuthCallbackPage />
<Route path=path!("home") view=student::StudentHomePage />
<Route path=path!("demographics") view=student::StudentDemographicsPage />
<Route path=path!("eligibility") view=student::StudentEligibilityPage />
<Route path=path!("additional") view=student::AdditionalPage />
<Route path=path!("additional/academics") view=student::StudentAcademicsPage />
<Route path=path!("additional/athletics") view=student::StudentAthleticsPage />
<Route
path=path!("additional/extracurriculars")
view=student::StudentExtracurricularsPage
/>
<Route
path=path!("additional/work-experience")
view=student::StudentWorkExperiencePage
/>
<Route path=path!("additional/university") view=student::StudentUniversityPage />
<Route path=path!("additional/family-info") view=student::StudentFamilyPage />
<Route path=path!("additional/financials") view=student::StudentFinancialPage />
<Route
path=path!("additional/specifics")
view=student::StudentSpecificRequirementsPage
/>
<Route path=path!("demographics") view=student::ClosedForm />
<Route path=path!("eligibility") view=student::ClosedForm />
<Route path=path!("additional") view=student::ClosedForm />
<Route path=path!("additional/academics") view=student::ClosedForm />
<Route path=path!("additional/athletics") view=student::ClosedForm />
<Route path=path!("additional/extracurriculars") view=student::ClosedForm />
<Route path=path!("additional/work-experience") view=student::ClosedForm />
<Route path=path!("additional/university") view=student::ClosedForm />
<Route path=path!("additional/family-info") view=student::ClosedForm />
<Route path=path!("additional/financials") view=student::ClosedForm />
<Route path=path!("additional/specifics") view=student::ClosedForm />
</ParentRoute>
}
.into_inner()
+1 -1
View File
@@ -15,7 +15,7 @@ use std::collections::HashMap;
/// now, the table will simply create a single massive `Map` field that contains all the stored
/// information in the `HashMap`. We'd prefer to be able to get single values if possible,
/// especially for interoperability with other applications, should they be created.
#[derive(Debug, Clone, Serialize, Deserialize, Reactive)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Reactive)]
pub struct ExpandableInfo {
/// The main key member. Required and used to get/put the information in a database.
pub subject: String,
+170 -15
View File
@@ -1,13 +1,35 @@
use std::collections::HashMap;
use indexmap::IndexMap;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub enum SchemaType {
/// Indicates a `ValueType::String` value.
#[default]
String,
/// Indicates a `ValueType::Number` value.
Number,
List,
Map
/// Indicates a `ValueType::List` containing only strings or numbers.
PrimitiveList,
/// Indicates a `ValueType::List` containing only maps.
MapList,
/// Indicates a `ValueType::Map`.
Map,
}
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub enum SchemaContainerStyle {
#[default]
Header,
Capsule,
}
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub enum SchemaHeaderStyle {
#[default]
None,
MainHeader,
SubsectionHeader,
Bold,
}
/// # Schema Node
@@ -55,12 +77,16 @@ pub enum SchemaType {
/// This schema does not currently support display of a single nested member, like a single map
/// within a list. This means that you will have to display any listed or mapped information as a
/// list. Consider this when designing the data.
#[derive(Deserialize, Serialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub struct SchemaNode {
/// The display style of the data. Refer to [`SchemaContainerStyle`] for more information.
pub container_style: SchemaContainerStyle,
/// The datatype of the data found using the `data_member` key.
pub data_type: SchemaType,
/// The header of this display.
pub header: String,
/// The style of the header's display.
pub header_style: SchemaHeaderStyle,
/// When used with a SchemaType::List, this determines the datatype and children for every entry
/// in the list. Some examples include a list of maps, where we use the `item_template` to define
/// the SchemaType::Map, and all its children. This is then mapped across all entries in the list,
@@ -69,16 +95,28 @@ pub struct SchemaNode {
/// When used with a SchemaType::Map, this determines what children from the map are displayed
/// and how. It is ignored with all other SchemaType variants.
pub children: IndexMap<String, SchemaNode>,
/// The primary key for iteration over maps. Used when there is a list of maps.
pub primary_key: Option<String>,
}
impl SchemaNode {
pub fn new(data_type: SchemaType) -> Self {
SchemaNode {
data_type,
header: "".to_string(),
item_template: None,
children: IndexMap::new(),
}
/// Creates a new [`SchemaBuilder`].
pub fn builder() -> SchemaBuilder {
SchemaBuilder::default()
}
}
#[derive(Default, Debug, Clone)]
pub struct SchemaBuilder {
container_style: SchemaContainerStyle,
header: String,
header_style: SchemaHeaderStyle,
}
impl SchemaBuilder {
pub fn container(mut self, style: SchemaContainerStyle) -> Self {
self.container_style = style;
self
}
pub fn header(mut self, header: impl Into<String>) -> Self {
@@ -86,13 +124,130 @@ impl SchemaNode {
self
}
pub fn child(mut self, data_member: impl Into<String>, node: SchemaNode) -> Self {
self.children.insert(data_member.into(), node);
pub fn header_style(mut self, header_style: SchemaHeaderStyle) -> Self {
self.header_style = header_style;
self
}
pub fn primitive_list(self) -> SchemaPrimitiveListBuilder {
SchemaPrimitiveListBuilder::new(self)
}
pub fn map_list(self) -> SchemaMapListBuilder {
SchemaMapListBuilder::new(self)
}
pub fn map(self) -> SchemaMapBuilder {
SchemaMapBuilder::new(self)
}
pub fn string(self) -> SchemaPrimitiveBuilder {
SchemaPrimitiveBuilder::new(self, SchemaType::String)
}
pub fn number(self) -> SchemaPrimitiveBuilder {
SchemaPrimitiveBuilder::new(self, SchemaType::Number)
}
fn into_node(self) -> SchemaNode {
SchemaNode {
container_style: self.container_style,
header: self.header,
header_style: self.header_style,
..Default::default()
}
}
}
#[derive(Default, Debug, Clone)]
pub struct SchemaPrimitiveListBuilder {
builder: SchemaBuilder,
}
impl SchemaPrimitiveListBuilder {
fn new(builder: SchemaBuilder) -> Self {
Self { builder }
}
}
#[derive(Default, Debug, Clone)]
pub struct SchemaMapListBuilder {
builder: SchemaBuilder,
item_template: Option<Box<SchemaNode>>,
primary_key: String,
}
impl SchemaMapListBuilder {
fn new(builder: SchemaBuilder) -> Self {
Self {
builder,
..Default::default()
}
}
pub fn item_template(mut self, node: SchemaNode) -> Self {
self.item_template = Some(Box::new(node));
self
}
pub fn primary_key(mut self, primary_key: impl Into<String>) -> Self {
self.primary_key = primary_key.into();
self
}
pub fn build(self) -> SchemaNode {
SchemaNode {
data_type: SchemaType::MapList,
item_template: self.item_template,
primary_key: Some(self.primary_key),
..self.builder.into_node()
}
}
}
#[derive(Default, Debug, Clone)]
pub struct SchemaMapBuilder {
builder: SchemaBuilder,
children: IndexMap<String, SchemaNode>,
}
impl SchemaMapBuilder {
fn new(builder: SchemaBuilder) -> Self {
Self {
builder,
..Default::default()
}
}
pub fn child(mut self, data_member: impl Into<String>, node: SchemaNode) -> Self {
self.children.insert(data_member.into(), node);
self
}
pub fn build(self) -> SchemaNode {
SchemaNode {
data_type: SchemaType::Map,
children: self.children,
..self.builder.into_node()
}
}
}
#[derive(Default, Debug, Clone)]
pub struct SchemaPrimitiveBuilder {
builder: SchemaBuilder,
data_type: SchemaType,
}
impl SchemaPrimitiveBuilder {
fn new(builder: SchemaBuilder, data_type: SchemaType) -> Self {
Self { data_type, builder }
}
pub fn build(self) -> SchemaNode {
SchemaNode {
data_type: self.data_type,
..self.builder.into_node()
}
}
}
+3
View File
@@ -24,4 +24,7 @@ pub struct UserClaims {
pub username: String,
/// 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")]
pub groups: Vec<String>,
}
+1 -1
View File
@@ -8,7 +8,7 @@ pub fn ActionButton(
) -> impl IntoView {
view! {
<button
class="m-1.5 p-2.5 min-w-20 rounded-lg font-semibold text-white transition-all duration-400 cursor-pointer"
class="flex-1 m-1.5 p-2.5 min-w-20 rounded-lg font-semibold text-white transition-all duration-400 cursor-pointer"
class=(["bg-red-800", "hover:bg-red-900"], move || !disabled.get())
class=(["bg-gray-600", "pointer-events-none"], move || disabled.get())
r#type=button_type
+207 -217
View File
@@ -1,263 +1,253 @@
use crate::common::{SchemaNode, SchemaType, ValueType};
use indexmap::IndexMap;
use leptos::logging::debug_log;
use crate::common::{SchemaContainerStyle, SchemaHeaderStyle, SchemaNode, SchemaType, ValueType};
use leptos::prelude::*;
use std::collections::HashMap;
/// # Value Display Component
/// This component displays any data from a given `HashMap<String, ValueType>`. It requires a schema
/// to tell it how to display the information, which is covered in the [`SchemaNode`] documentation.
#[component]
pub fn ValueDisplay(
#[prop(into)] schema: Signal<SchemaNode>,
#[prop(into, optional)] data_member: Signal<String>,
#[prop(into)] data_map: Signal<HashMap<String, ValueType>>,
pub fn DataDisplay(
/// The schema that this component should use to display the given data.
#[prop(into)]
schema: Signal<SchemaNode>,
/// The starting key in the data map.
#[prop(into, optional)]
data_member: Signal<String>,
/// A `HashMap` containing all the data that should be displayed.
#[prop(into)]
data_map: Signal<HashMap<String, ValueType>>,
) -> impl IntoView {
let data_value = Memo::new(move |_| {
if data_member.get().is_empty() {
ValueType::Map(Some(data_map.get()))
// The schema should contain information about the header type and styling, along with the
// information display. This is rendered as part of the DataDisplayWrapper, which is simply a
// wrapper around the information to be displayed.
// The children are all SchemaNodes as well, meaning that this component needs to call itself
// for each of these children. They will of course contain all the same information that we
// expect here.
// The actual displayed information is controlled through a DataDisplayView, which will accept
// settings for how the information is parsed. For example, rendering a list may be done by
// a string join operation, or it may be a list of maps, each of which needs to be displayed with
// a specific set of children. The SchemaNode must be able to handle this, and these options
// should be given to the DataDisplayView somehow.
// As the display_value here is actually an Option<ValueType>, we want to be able to handle cases
// where the display is given no values. In this case, the SchemaNode needs to specify whether
// a missing value is expected or an error.
let display_value = Memo::new(move |_| {
let member = data_member.get();
if member.is_empty() {
Some(ValueType::Map(Some(data_map.get())))
} else {
data_map
.get()
.get(&data_member.get())
.map(|v| v.clone())
.unwrap_or_default()
data_map.get().get(&member).cloned()
}
});
view! {
{move || {
match schema.get().data_type {
SchemaType::String | SchemaType::Number => {
view! {
<PrimitiveDisplay
header=move || schema.get().header
value=data_value
nested=true
/>
}
.into_any()
}
SchemaType::List => {
view! {
<ListDisplay
header=move || schema.get().header
value=data_value
item_template=move || {
schema
.get()
.item_template
.unwrap_or(Box::from(SchemaNode::new(SchemaType::List)))
}
nested=true
/>
}
.into_any()
}
SchemaType::Map => {
view! {
<MapDisplay
value=data_value
header=move || schema.get().header
schema=move || schema.get().children
nested=true
/>
}
.into_any()
}
}
}}
<DataDisplayWrapper
container_style=schema.get().container_style
header_style=schema.get().header_style
header_text=schema.get().header
>
<DataDisplayView display_data=display_value schema=schema />
</DataDisplayWrapper>
}
}
/// # Display Wrapper Component
/// This component is designed to wrap information for the [`ValueDisplay`] component. It allows
/// for simple uniform styling of the header and capsule, along with the ability for users to
/// collapse the information.
#[component]
fn DisplayWrapper(
#[prop(into, optional)] header: Signal<String>,
#[prop(into, optional)] nested: Signal<bool>,
fn DataDisplayWrapper(
#[prop(into)] container_style: SchemaContainerStyle,
#[prop(into)] header_style: SchemaHeaderStyle,
#[prop(into)] header_text: Signal<String>,
children: Children,
) -> impl IntoView {
// This component takes a wrapper style and displays the "chrome" of the interface. It has two
// options: Text Headers or Capsules. This will then render a DataDisplayHeader, which handles
// the styling for the text inside the header specifically.
// The capsule style is a colored header and a rounded container.
// The text header style is an invisible container.
let expanded = RwSignal::new(true);
view! {
<div class="m-3 rounded-lg shadow-lg flex flex-col">
<div
class="p-2 px-3 font-bold rounded-t-lg cursor-pointer"
class=(["bg-gray-300"], move || !nested.get())
class=(["text-white", "bg-red-900"], move || nested.get())
class=(["rounded-lg"], move || !expanded.get())
on:click=move |_| expanded.update(|b| *b = !*b)
>
{header}
</div>
<div
class="grid transition-all ease-in-out"
style:grid-template-rows=move || if expanded.get() { "1fr" } else { "0fr" }
>
<div class="min-h-0 overflow-hidden">
<div class="p-2">{children()}</div>
match container_style {
SchemaContainerStyle::Header => {
// Creates a simple container that only has a header and a series of children.
// The spacing between the header and children is controlled by the header style.
// This type of container is not collapsable at the moment.
view! {
<div class="flex flex-col w-full text-left">
<div class="self-start w-full">
<DataDisplayHeader header_text=header_text header_style=header_style />
<div class="p-2 flex flex-col gap-1">{children()}</div>
</div>
</div>
</div>
</div>
}
}
/// # Primitive Value Display Component
/// This component allows for the display of a `ValueType::String` or `ValueType::Number`.
#[component]
fn PrimitiveDisplay(
#[prop(into)] header: Signal<String>,
#[prop(into)] value: Signal<ValueType>,
#[prop(into, optional)] nested: Signal<bool>,
) -> impl IntoView {
let display_string = Memo::new(move |_| {
let value = value.get();
match value {
ValueType::String(_) | ValueType::Number(_) => value.to_string(),
_ => "Value is not a string.".to_string(),
}
.into_any()
}
SchemaContainerStyle::Capsule => {
// Creates a capsule container - it is collapsable, with rounded corners and a colored
// header.
view! {
<div class="self-stretch rounded-lg shadow-lg flex flex-col w-full text-center">
<div
class="p-2 px-3 rounded-t-lg cursor-pointer bg-red-800 text-white"
class=(["rounded-lg"], move || !expanded.get())
on:click=move |_| expanded.update(|b| *b = !*b)
>
<DataDisplayHeader header_text=header_text header_style=header_style />
</div>
<div
class="grid transition-all ease-in-out"
style:grid-template-rows=move || if expanded.get() { "1fr" } else { "0fr" }
>
<div class="min-h-0 overflow-hidden">
<div class="p-2 flex flex-col gap-1 flex-1">{children()}</div>
</div>
</div>
</div>
}
.into_any()
}
});
view! {
<DisplayWrapper header=header nested=nested>
<div>{display_string}</div>
</DisplayWrapper>
}
}
/// # List Value Display Component
/// This component allows for the display of a `ValueType::List`. It will show an error if passed
/// anything other than a list. It requires an item template that tells the list how to display
/// each value inside of it, most specifically the type of data inside of it.
#[component]
fn ListDisplay(
#[prop(into)] item_template: Signal<Box<SchemaNode>>,
#[prop(into)] value: Signal<ValueType>,
#[prop(into)] header: Signal<String>,
#[prop(into, optional)] nested: Signal<bool>,
fn DataDisplayHeader(
#[prop(into)] header_style: SchemaHeaderStyle,
#[prop(into)] header_text: Signal<String>,
) -> impl IntoView {
// Unwrap the value and check if it's the correct type. To be displayed with this component, it
// MUST be a ValueType::List.
let display_list = Memo::new(move |_| {
let ValueType::List(Some(list)) = value.get() else {
return Vec::new();
};
// This component will take a header style and displays the corresponding text. It has a few
// options, all of which are defined in the HeaderStyle enum.
list.into_iter()
.enumerate()
.collect::<Vec<(usize, ValueType)>>()
});
let is_valid = Memo::new(move |_| matches!(value.get(), ValueType::List(_)));
let item_header = Memo::new(move |_| item_template.get().header);
view! {
<DisplayWrapper header=header nested=nested>
<For
each=move || display_list.get()
key=|(idx, _)| idx.clone()
children=move |(idx, entry)| {
match item_template.get().data_type {
SchemaType::String | SchemaType::Number => {
view! { <PrimitiveDisplay value=entry header=item_header /> }.into_any()
}
SchemaType::List => {
view! {
<div>"Nested lists are not currently supported for display."</div>
}
.into_any()
}
SchemaType::Map => {
let map_header = format!("{} {}", item_template.get().header, idx + 1);
view! {
<MapDisplay
value=entry
header=map_header
schema=move || item_template.get().children
/>
}
.into_any()
}
}
}
/>
</DisplayWrapper>
match header_style {
SchemaHeaderStyle::MainHeader => {
view! { <div class="text-3xl font-bold my-2">{header_text}</div> }.into_any()
}
SchemaHeaderStyle::SubsectionHeader => {
view! { <div class="text-xl font-bold">{header_text}</div> }.into_any()
}
SchemaHeaderStyle::Bold => view! { <div class="font-bold">{header_text}</div> }.into_any(),
SchemaHeaderStyle::None => view! {}.into_any(),
}
}
/// # Map Data Display Component
/// This component allows for the display of a `ValueType::Map`. The nested information must be defined
/// by a [`SchemaNode`], which tells the display what keys in the value map to search for the
/// display information.
#[component]
fn MapDisplay(
#[prop(into)] value: Signal<ValueType>,
#[prop(into)] header: Signal<String>,
#[prop(into)] schema: Signal<IndexMap<String, SchemaNode>>,
#[prop(into, optional)] nested: Signal<bool>,
fn DataDisplayView(
#[prop(into)] display_data: Signal<Option<ValueType>>,
#[prop(into)] schema: Signal<SchemaNode>,
) -> impl IntoView {
// Unwrap the value and check if it is the correct type. It must be a ValueType::Map.
let display_map = Memo::new(move |_| {
let ValueType::Map(Some(map)) = value.get() else {
return HashMap::new();
};
// This is the part of the data display that does most of the work. It will take the values
// found in the DataDisplay component and perform the logic that actually displays the
// information. This component is only displayed if there is Some(value) in the DataDisplay.
map
});
// In the case that this is displaying a Map or List, it will recursively call the DataDisplay
// component.
Effect::new(move || {
debug_log!("Current map values: {:?}", display_map.get());
});
// If this is displaying a PrimitiveList, it will flatten and join all the values into a single
// string, then display that string.
let is_valid = Memo::new(move |_| matches!(value.get(), ValueType::Map(_)));
let data_type = schema.get().data_type;
view! {
<DisplayWrapper header=header nested=nested>
<For
each=move || schema.get()
key=|(k, _)| k.clone()
children=move |(member, child_schema)| {
let display_value = Memo::new(move |_| {
display_map.get().get(&member).map(|v| v.clone()).unwrap_or_default()
});
match child_schema.data_type {
SchemaType::String | SchemaType::Number => {
view! {
<PrimitiveDisplay value=display_value header=child_schema.header />
}
.into_any()
// Matches the specific data type. Displays errors if the given display data does not match.
match data_type {
SchemaType::String => {
let value = Signal::derive(move || {
display_data
.get()
.map(|v| v.as_string().ok().flatten())
.flatten()
.unwrap_or("N/A".to_string())
});
view! { <div>{value}</div> }.into_any()
}
SchemaType::Number => {
let value = Signal::derive(move || {
display_data
.get()
.map(|v| v.as_number().ok().flatten())
.flatten()
.unwrap_or("N/A".to_string())
});
view! { <div>{value}</div> }.into_any()
}
SchemaType::PrimitiveList => {
let value = Signal::derive(move || {
display_data
.get()
.map(|v| v.as_list().ok().flatten())
.flatten()
.unwrap_or_default()
.iter()
.map(|v| v.to_string())
.collect::<Vec<String>>()
.join(", ")
});
view! { <div>{value}</div> }.into_any()
}
SchemaType::MapList => {
let list = Signal::derive(move || {
display_data
.get()
.map(|v| v.as_list().ok().flatten())
.flatten()
.unwrap_or_default()
.iter()
.filter_map(|v| v.as_map().ok().flatten().clone())
.collect::<Vec<HashMap<String, ValueType>>>()
});
let primary_key = StoredValue::new(schema.get().primary_key.unwrap_or_default());
view! {
<Show
when=move || !list.get().is_empty()
fallback=|| view! { <div class="w-full text-center">"List is empty."</div> }
>
<For
each=move || list.get()
key=move |map| {
map.get(&primary_key.get_value())
.map(|v| v.to_string())
.unwrap_or_default()
}
SchemaType::List => {
let template = child_schema
.item_template
.unwrap_or(Box::from(SchemaNode::new(SchemaType::List)));
children=move |map| {
view! {
<ListDisplay
value=display_value
item_template=template
header=child_schema.header
<DataDisplay
schema=*schema.get().item_template.unwrap_or_default()
data_map=map
/>
}
.into_any()
}
SchemaType::Map => {
/>
</Show>
}
.into_any()
}
SchemaType::Map => {
let map = Signal::derive(move || {
display_data
.get()
.map(|v| v.as_map().ok().flatten())
.flatten()
});
view! {
<Show
when=move || map.get().is_some()
fallback=|| view! { <div class="text-red-500">"Map was not found."</div> }
>
<For
each=move || schema.get().children
key=|(key, _)| key.clone()
children=move |(key, schema)| {
view! {
<MapDisplay
value=display_value
schema=child_schema.children
header=child_schema.header
<DataDisplay
schema=schema
data_member=key
data_map=Signal::derive(move || map.get().unwrap_or_default())
/>
}
.into_any()
}
}
}
/>
</DisplayWrapper>
/>
</Show>
}
.into_any()
}
}
}
+16 -4
View File
@@ -1,6 +1,6 @@
use crate::pages::api::files::{DeleteFile, upload_file};
use leptos::html::{Input, Label};
use leptos::logging::debug_log;
use leptos::logging::{debug_error, debug_log};
use leptos::prelude::*;
use leptos::web_sys::{File, FormData};
use leptos_icons::Icon;
@@ -28,6 +28,7 @@ pub fn FileDrop(
let file_name_list = RwSignal::new(Vec::new());
let hovering = RwSignal::new(false);
let error_msg = RwSignal::new(String::new());
let name = StoredValue::new(name);
let form_id = StoredValue::new(form_id);
let auth = expect_context::<AuthSignal>();
@@ -172,10 +173,18 @@ pub fn FileDrop(
existing_files
.get()
.map(|result| {
if let Ok(files) = result {
file_name_list.set(files);
match result {
Ok(files) => {
error_msg.set("".to_string());
file_name_list.set(files);
}
Err(err) => {
let msg = err.to_string();
debug_error!("Error while getting files: {}", msg);
error_msg.set(msg);
}
}
})
});
}}
<label
node_ref=zone_ref
@@ -194,6 +203,9 @@ pub fn FileDrop(
<div class="text-sm text-gray-400">"Or click to select a file"</div>
<input node_ref=input_ref type="file" class="hidden" on:change=on_input_change />
</label>
<Show when=move || !error_msg.get().is_empty()>
<div class="flex-1">{error_msg}</div>
</Show>
<For
each=move || file_name_list.get()
key=|file| file.clone()
+37 -44
View File
@@ -14,10 +14,9 @@ use crate::common::{
ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison,
TextComparison, TextListComparison, ValueType,
};
use std::collections::HashMap;
use leptos::prelude::ServerFnError;
use leptos::server;
use std::collections::HashMap;
/// Helper function to create all sports comparisons. Only used to create initial
/// comparison data, and should be removed upon completion of a comparison editor page.
@@ -250,14 +249,18 @@ fn create_major_comparisons() -> Vec<ComparisonData> {
let ids = display_texts
.iter()
.map(|text|
format!("{}_{}", "major", text.to_lowercase().replace(" ", "_").replace("/", "_"))
)
.map(|text| {
format!(
"{}_{}",
"major",
text.to_lowercase().replace(" ", "_").replace("/", "_")
)
})
.collect::<Vec<String>>();
ids.iter().zip(display_texts).map(|(id, display_text)| {
create_major_comparison(id, display_text)
})
ids.iter()
.zip(display_texts)
.map(|(id, display_text)| create_major_comparison(id, display_text))
.collect::<Vec<ComparisonData>>()
}
@@ -280,13 +283,11 @@ fn make_comp_list() -> Vec<ComparisonData> {
"Academic Information",
"Math SAT Score > 650",
);
let service_hours_20 = ComparisonData::new(
"service_hours_20",
"service_hours",
ComparisonType::Number(
NumberComparison::GreaterThanOrEqual
),
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
ValueType::Number(Some(20.to_string())),
"Community Service",
"20+ service hours",
@@ -295,9 +296,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
let service_hours_25 = ComparisonData::new(
"service_hours_25",
"service_hours",
ComparisonType::Number(
NumberComparison::GreaterThanOrEqual
),
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
ValueType::Number(Some(25.to_string())),
"Community Service",
"25+ service hours",
@@ -306,9 +305,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
let service_hours_30 = ComparisonData::new(
"service_hours_30",
"service_hours",
ComparisonType::Number(
NumberComparison::GreaterThanOrEqual
),
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
ValueType::Number(Some(30.to_string())),
"Community Service",
"30+ service hours",
@@ -334,7 +331,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
let attended_bas = ComparisonData::new(
"attended_bas",
"attended_bas",
"attend_bas",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors",
@@ -365,7 +362,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors",
"Participated in Pomperaug Youth Baseball"
"Participated in Pomperaug Youth Baseball",
);
let aquatic_club = ComparisonData::new(
@@ -374,25 +371,25 @@ fn make_comp_list() -> Vec<ComparisonData> {
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors",
"Participated in Panthers Aquatic Club"
"Participated in Panthers Aquatic Club",
);
let pea_member = ComparisonData::new(
"pea_member",
"pea_member",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors",
"Has a Parent in the PEA"
"Has a Parent in the PEA",
);
let music_program = ComparisonData::new(
"music_program",
"music_program",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Sports Participation",
"PHS Music Program"
"PHS Music Program",
);
let mut sports_comps = create_sports_comparisons();
@@ -412,7 +409,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
youth_baseball,
aquatic_club,
pea_member,
music_program
music_program,
];
comp_list.append(&mut sports_comps);
@@ -438,6 +435,7 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
.await
{
let msg = err.message().unwrap_or("An unknown error occurred");
leptos::logging::error!("{}", msg);
return Err(ServerFnError::new(msg));
}
}
@@ -453,15 +451,9 @@ pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError>
// Query the database for all comparisons. The client is only going to use the
// id and display text, but we'll return the whole thing.
match client
.scan()
.table_name(COMPARISONS_TABLE)
.send()
.await
{
match client.scan().table_name(COMPARISONS_TABLE).send().await {
Ok(output) => {
if let Some(items) = output.items {
log!("Found comparisons from API: {:?}", items);
Ok(serde_dynamo::from_items(items)?)
} else {
Ok(vec![])
@@ -469,22 +461,19 @@ pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError>
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
leptos::logging::error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
}
#[server]
pub async fn get_comparisons_categorized() -> Result<HashMap<String, Vec<ComparisonData>>, ServerFnError> {
pub async fn get_comparisons_categorized()
-> Result<HashMap<String, Vec<ComparisonData>>, ServerFnError> {
let client = create_dynamo_client().await;
log!("Getting all comparisons from the database, by category.");
match client
.scan()
.table_name(COMPARISONS_TABLE)
.send()
.await
{
match client.scan().table_name(COMPARISONS_TABLE).send().await {
Ok(output) => {
if let Some(items) = output.items {
log!("Found comparisons, categorizing.");
@@ -492,23 +481,27 @@ pub async fn get_comparisons_categorized() -> Result<HashMap<String, Vec<Compari
let items: Vec<ComparisonData> = serde_dynamo::from_items(items)?;
let categorized = items.iter().fold(
HashMap::<String, Vec<ComparisonData>>::new(), |mut map, comp| {
// Load each comparison into a map, then return that map.
HashMap::<String, Vec<ComparisonData>>::new(),
|mut map, comp| {
// Load each comparison into a map, then return that map.
map.entry(comp.category.clone())
.and_modify(|vec| vec.push(comp.clone()))
.or_insert(vec![comp.clone()]);
map
}
},
);
Ok(categorized)
} else {
Err(ServerFnError::new("Couldn't find any comparisons."))
let msg = "Couldn't find any comparisons.";
leptos::logging::error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
leptos::logging::error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
+32 -32
View File
@@ -1,61 +1,60 @@
use std::collections::HashMap;
use leptos::logging::log;
use leptos::prelude::*;
use crate::common::DateInfo;
use leptos::logging::{error, log};
use leptos::prelude::*;
use std::collections::HashMap;
#[cfg(feature = "ssr")]
use super::DATES_TABLE;
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{
types::{WriteRequest, PutRequest},
Client as DynamoClient
};
#[cfg(feature = "ssr")]
use crate::utils::server::create_dynamo_client;
#[cfg(feature = "ssr")]
use std::sync::Arc;
use aws_sdk_dynamodb::{
Client as DynamoClient,
error::ProvideErrorMetadata,
types::{PutRequest, WriteRequest},
};
#[server]
pub async fn create_dates(dates: Vec<DateInfo>) -> Result<(), ServerFnError> {
// Serialize and put dates into the database.
let client = create_dynamo_client().await;
log!("Creating dates: {:?}", dates);
let requests = dates
.into_iter()
.map(|date| {
let item = serde_dynamo::to_item(&date)
.unwrap_or_default();
let put_request = PutRequest::builder()
.set_item(Some(item))
.build()
.expect("Couldn't build request, item is not present");
WriteRequest::builder().put_request(put_request).build()
.filter_map(|date| {
let item = serde_dynamo::to_item(&date).unwrap_or_default();
let put_request = PutRequest::builder().set_item(Some(item)).build().ok()?;
Some(WriteRequest::builder().put_request(put_request).build())
})
.collect::<Vec<WriteRequest>>();
let mut request_map = HashMap::new();
request_map.insert(DATES_TABLE.to_string(), requests);
client
.batch_write_item()
.set_request_items(Some(request_map))
.send()
.await
.map(|_| ())
.map_err(ServerFnError::from)
.map_err(|err| {
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})
}
#[server]
pub async fn get_important_dates() -> Result<Vec<DateInfo>, ServerFnError> {
log!("Getting important dates list");
let client = create_dynamo_client().await;
client
.scan()
.table_name(DATES_TABLE)
@@ -65,14 +64,15 @@ pub async fn get_important_dates() -> Result<Vec<DateInfo>, ServerFnError> {
if let Some(items) = output.items {
items
.into_iter()
.filter_map(|item| {
serde_dynamo::from_item::<_, DateInfo>(item)
.ok()
})
.filter_map(|item| serde_dynamo::from_item::<_, DateInfo>(item).ok())
.collect::<Vec<DateInfo>>()
} else {
Vec::new()
}
})
.map_err(ServerFnError::from)
.map_err(|err| {
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})
}
-1
View File
@@ -231,7 +231,6 @@ pub async fn get_scholarship_csv() -> Result<Vec<u8>, ServerFnError> {
writer.flush()?;
let result = writer.into_inner()?;
debug_log!("{:?}", String::from_utf8(result.clone()));
Ok(result)
}
+53 -18
View File
@@ -1,4 +1,5 @@
use leptos::logging::debug_log;
use leptos::leptos_dom::error;
use leptos::logging::debug_log;
use leptos::prelude::*;
use server_fn::codec::{MultipartData, MultipartFormData};
@@ -7,7 +8,11 @@ mod imports {
pub use super::super::{MAIN_TABLE_NAME, S3_BUCKET_NAME};
pub use crate::pages::api::tokens::validate_and_get_token_info;
pub use crate::utils::server::create_aws_config;
pub use aws_sdk_dynamodb::types::{AttributeValue, ReturnValue, Select};
pub use aws_sdk_dynamodb::{
error::ProvideErrorMetadata as _,
types::{AttributeValue, ReturnValue, Select},
};
pub use aws_sdk_s3::error::ProvideErrorMetadata as _;
}
#[server(input = MultipartFormData)]
@@ -57,6 +62,9 @@ pub async fn upload_file(data: MultipartData) -> Result<String, ServerFnError> {
if input_name.is_empty() {
return Err(ServerFnError::new("Missing input file"));
}
if file_bytes.is_empty() {
return Err(ServerFnError::new("Missing file contents"));
}
let user_claims = validate_and_get_token_info(access_token).await?;
let subject = user_claims.subject;
@@ -75,7 +83,14 @@ pub async fn upload_file(data: MultipartData) -> Result<String, ServerFnError> {
.send()
.await
.map(|_| ())
.map_err(|e| ServerFnError::new(format!("Couldn't put file to S3: {}", e.to_string())))?;
.map_err(|e| {
let msg = format!(
"Couldn't put file to S3: {}",
e.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})?;
// Besides uploading the file to S3, we also want to store a FILE entry in Dynamo.
// This will allow the server to quickly return a list of all files that a specific user has
@@ -101,11 +116,13 @@ pub async fn upload_file(data: MultipartData) -> Result<String, ServerFnError> {
.item("file_key".to_string(), AttributeValue::S(key.clone()))
.send()
.await
.map_err(|e| {
ServerFnError::new(format!(
.map_err(|err| {
let msg = format!(
"Couldn't put file to Dynamo, file rolled back: {}",
e.to_string()
))
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
});
// If Dynamo fails, we want to handle the error by rolling back the file. Then, we return a failure.
@@ -116,11 +133,13 @@ pub async fn upload_file(data: MultipartData) -> Result<String, ServerFnError> {
.key(&key)
.send()
.await
.map_err(|e| {
ServerFnError::new(format!(
"Couldn't roll back S3 operation: {}",
e.to_string()
))
.map_err(|err| {
let msg = format!(
"Failed to rollback Dynamo entry: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})?;
return Err(err);
@@ -167,7 +186,14 @@ pub async fn delete_file(
.key(key)
.send()
.await
.map_err(ServerFnError::from);
.map_err(|err| {
let msg = format!(
"Failed to put file into S3: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
});
if let Err(err) = s3_result {
// Add the key back to Dynamo and return an error.
@@ -178,10 +204,12 @@ pub async fn delete_file(
.send()
.await
.map_err(|err| {
ServerFnError::new(format!(
"Couldn't roll back Dynamo operation: {}",
err.to_string()
))
let msg = format!(
"Failed to rollback Dynamo entry: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})?;
return Err(err);
@@ -230,5 +258,12 @@ pub async fn list_files(
})
.collect::<Vec<String>>()
})
.map_err(|e| ServerFnError::new(format!("Couldn't list files: {}", e.to_string())))
.map_err(|err| {
let msg = format!(
"Failed to get files: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})
}
+2 -2
View File
@@ -2,11 +2,11 @@ mod admin;
mod comparisons;
mod dates;
pub mod exports;
pub mod files;
mod providers;
mod scholarships;
pub mod students;
pub mod files;
mod tokens;
pub(crate) mod tokens;
pub use admin::*;
pub use comparisons::*;
+23 -12
View File
@@ -1,8 +1,8 @@
use leptos::prelude::*;
use leptos::logging::debug_log;
use std::collections::HashMap;
use crate::common::ValueType;
use leptos::logging::{debug_log, error};
use leptos::prelude::*;
use leptos::server_fn::codec::Json;
use crate::common::ValueType;
use std::collections::HashMap;
#[cfg(feature = "ssr")]
use crate::utils::server::{create_dynamo_client, into_attr_map};
@@ -12,8 +12,8 @@ static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
#[server]
pub async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueType>, ServerFnError> {
use aws_sdk_dynamodb::types::AttributeValue;
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
use aws_sdk_dynamodb::types::AttributeValue;
let client = create_dynamo_client().await;
@@ -31,29 +31,39 @@ pub async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueTyp
return Ok(HashMap::new());
};
let map = item.iter().map(|(key, val)| {
let val_type = ValueType::from(val);
let map = item
.iter()
.map(|(key, val)| {
let val_type = ValueType::from(val);
(key.clone(), val_type)
}).collect::<HashMap<String, ValueType>>();
(key.clone(), val_type)
})
.collect::<HashMap<String, ValueType>>();
Ok(map)
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
}
#[server(input = Json)]
pub async fn put_provider_contact(id: String, contact_info: HashMap<String, ValueType>) -> Result<(), ServerFnError> {
pub async fn put_provider_contact(
id: String,
contact_info: HashMap<String, ValueType>,
) -> Result<(), ServerFnError> {
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await;
let mut item = into_attr_map(contact_info);
item.insert("subject".to_owned(), aws_sdk_dynamodb::types::AttributeValue::S(id));
item.insert(
"subject".to_owned(),
aws_sdk_dynamodb::types::AttributeValue::S(id),
);
match client
.put_item()
@@ -65,7 +75,8 @@ pub async fn put_provider_contact(id: String, contact_info: HashMap<String, Valu
Ok(_) => Ok(()),
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
}
}
+90 -56
View File
@@ -1,15 +1,11 @@
#[cfg(feature = "ssr")]
mod imports {
pub use leptos::logging::debug_log;
pub use aws_sdk_dynamodb::{
types::AttributeValue,
error::ProvideErrorMetadata,
};
pub use leptos::leptos_dom::log;
pub use uuid::Uuid;
pub use crate::utils::server::{create_dynamo_client};
pub use crate::common::ValueType;
pub use super::super::SCHOLARSHIPS_TABLE;
pub use crate::common::ValueType;
pub use crate::utils::server::create_dynamo_client;
pub use aws_sdk_dynamodb::{error::ProvideErrorMetadata, types::AttributeValue};
pub use leptos::logging::{debug_log, error, log};
pub use uuid::Uuid;
}
use crate::common::ExpandableInfo;
@@ -20,7 +16,7 @@ use leptos::server_fn::codec::Json;
#[server(GetScholarshipInfo)]
pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
// Perform the operation - we just want to return all data that's contained in this entry,
@@ -39,11 +35,14 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
Ok(serde_dynamo::from_item(item)?)
} else {
log!("Couldn't find scholarship with ID {:?}", id);
Err(ServerFnError::new("Couldn't find scholarship with given ID."))
Err(ServerFnError::new(
"Couldn't find scholarship with given ID.",
))
}
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
@@ -51,12 +50,15 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
#[server(CreateScholarshipInfo, input = Json)]
pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerFnError> {
use imports::*;
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
use imports::*;
let client = create_dynamo_client().await;
log!("Creating or updating scholarship with ID {:?}", info.subject);
log!(
"Creating or updating scholarship with ID {:?}",
info.subject
);
match client
.put_item()
@@ -68,6 +70,7 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
Ok(_) => Ok(()),
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
@@ -76,24 +79,20 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
#[server(GetAllScholarshipInfo)]
pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
debug_log!("Getting all scholarship info");
match client
.scan()
.table_name(SCHOLARSHIPS_TABLE)
.send()
.await
{
match client.scan().table_name(SCHOLARSHIPS_TABLE).send().await {
Ok(output) => {
if let Some(items) = output.items {
Ok(serde_dynamo::from_items(items)?)
} else {
Ok(Vec::new())
}
},
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
@@ -101,17 +100,25 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
/// Gets all scholarships that are associated with the given scholarship provider's ID.
#[server(GetProviderScholarships)]
pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<ExpandableInfo>, ServerFnError> {
pub async fn get_provider_scholarships(
provider_id: String,
) -> Result<Vec<ExpandableInfo>, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
debug_log!("Getting provider scholarships for provider with ID {:?}", provider_id);
debug_log!(
"Getting provider scholarships for provider with ID {:?}",
provider_id
);
match client
.scan()
.table_name(SCHOLARSHIPS_TABLE)
.expression_attribute_values(":id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?)
.expression_attribute_values(
":id",
serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?,
)
.filter_expression("provider_id = :id")
.send()
.await
@@ -124,7 +131,8 @@ pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<Expand
}
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
@@ -134,14 +142,20 @@ pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<Expand
#[server(RegisterScholarship)]
pub async fn register_scholarship(provider_id: String) -> Result<String, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
debug_log!("Creating scholarship for provider with ID {:?}", provider_id);
debug_log!(
"Creating scholarship for provider with ID {:?}",
provider_id
);
let mut current_uuid = Uuid::new_v4().to_string();
let mut item = ExpandableInfo::new(current_uuid.clone());
item.data.insert("provider_id".to_string(), ValueType::String(Some(provider_id)));
item.data.insert(
"provider_id".to_string(),
ValueType::String(Some(provider_id)),
);
loop {
let ser_item = serde_dynamo::to_item(&item)?;
match client
@@ -165,10 +179,12 @@ pub async fn register_scholarship(provider_id: String) -> Result<String, ServerF
log!("Current error: {:?}", err);
current_uuid = Uuid::new_v4().to_string();
item.subject = current_uuid.clone();
continue
continue;
}
_ => {
return Err(ServerFnError::new(err.message().unwrap_or("An unknown error occurred")));
return Err(ServerFnError::new(
err.message().unwrap_or("Unknown error occurred"),
));
}
}
}
@@ -178,41 +194,51 @@ pub async fn register_scholarship(provider_id: String) -> Result<String, ServerF
/// Deletes a provider's scholarship given their provider ID and scholarship ID.
#[server(DeleteProviderScholarship)]
pub async fn delete_provider_scholarship(provider_id: String, scholarship_id: String) -> Result<(), ServerFnError> {
pub async fn delete_provider_scholarship(
provider_id: String,
scholarship_id: String,
) -> Result<(), ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
debug_log!("Deleting scholarship with ID {:?} for provider with ID {:?}", scholarship_id, provider_id);
debug_log!(
"Deleting scholarship with ID {:?} for provider with ID {:?}",
scholarship_id,
provider_id
);
// When we delete a scholarship, we need to ensure that the provider's ID matches the scholarship,
// otherwise everyone can delete anyone else's scholarships.
match client
.delete_item()
.table_name(SCHOLARSHIPS_TABLE)
.key("subject", AttributeValue::S(scholarship_id))
.expression_attribute_values(":provider_id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?)
.expression_attribute_values(
":provider_id",
serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?,
)
.condition_expression("provider_id = :provider_id")
.send()
.await
{
Ok(_) => {
Ok(())
}
Ok(_) => Ok(()),
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
}
#[server]
pub async fn get_all_scholarships() -> Result<Vec<std::collections::HashMap<String, crate::common::ValueType>>, ServerFnError> {
pub async fn get_all_scholarships()
-> Result<Vec<std::collections::HashMap<String, crate::common::ValueType>>, ServerFnError> {
use imports::*;
use std::collections::HashMap;
let client = create_dynamo_client().await;
client
.scan()
.table_name(SCHOLARSHIPS_TABLE)
@@ -222,12 +248,20 @@ pub async fn get_all_scholarships() -> Result<Vec<std::collections::HashMap<Stri
let Some(items) = output.items else {
return Vec::new();
};
items.iter().map(|item| {
// Each item should be simply converted from an AttributeValue to a ValueType.
item.iter().map(|(k, v)| (k.clone(), ValueType::from(v)))
.collect::<HashMap<String, ValueType>>()
}).collect::<Vec<_>>()
items
.iter()
.map(|item| {
// Each item should be simply converted from an AttributeValue to a ValueType.
item.iter()
.map(|(k, v)| (k.clone(), ValueType::from(v)))
.collect::<HashMap<String, ValueType>>()
})
.collect::<Vec<_>>()
})
.map_err(|err| {
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})
.map_err(ServerFnError::from)
}
+346 -37
View File
@@ -1,38 +1,40 @@
use leptos::prelude::*;
use leptos::server_fn::codec::Json;
use std::collections::HashMap;
use std::io::Write;
#[cfg(feature = "ssr")]
mod imports {
pub use crate::utils::server::*;
pub use aws_sdk_dynamodb::types::AttributeValue;
pub use super::super::MAIN_TABLE_NAME;
pub use crate::common::ValueType;
pub use crate::pages::api::tokens::validate_and_get_token_info;
pub use crate::utils::server::*;
pub use aws_sdk_dynamodb::error::ProvideErrorMetadata;
pub use aws_sdk_dynamodb::types::AttributeValue;
pub use leptos::logging::{debug_log, error};
pub use std::collections::HashMap;
pub use leptos::logging::debug_log;
pub use zip::write::SimpleFileOptions;
}
#[server(input = Json)]
pub async fn put_student_data(
subject: String,
data_type: String,
data_map: std::collections::HashMap<String, crate::common::ValueType>
data_map: HashMap<String, crate::common::ValueType>,
) -> Result<(), ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
let mut data_map_attr = into_attr_map(data_map);
data_map_attr.insert(
"HK".into(),
AttributeValue::S(format!("STUDENT#{}", subject))
AttributeValue::S(format!("STUDENT#{}", subject)),
);
data_map_attr.insert(
"SK".into(),
AttributeValue::S(data_type.to_uppercase())
);
data_map_attr.insert("SK".into(), AttributeValue::S(data_type.to_uppercase()));
debug_log!("Inserting this item: {:?}", data_map_attr);
client
.put_item()
.table_name(MAIN_TABLE_NAME)
@@ -40,18 +42,25 @@ pub async fn put_student_data(
.send()
.await
.map(|_| ())
.map_err(ServerFnError::from)
.map_err(|err| {
let msg = format!(
"Couldn't put item into Dynamo: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})
}
#[server]
pub async fn get_student_data(
subject: String,
data_type: String,
) -> Result<std::collections::HashMap<String, crate::common::ValueType>, ServerFnError> {
) -> Result<HashMap<String, crate::common::ValueType>, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
client
.query()
.table_name(MAIN_TABLE_NAME)
@@ -65,37 +74,42 @@ pub async fn get_student_data(
let Some(list) = output.items else {
return HashMap::new();
};
let mut map = HashMap::new();
let _ = list
.into_iter()
.flatten()
.for_each(|(k, v)| {
map.insert(k, ValueType::from(&v));
});
let _ = list.into_iter().flatten().for_each(|(k, v)| {
map.insert(k, ValueType::from(&v));
});
map
})
.map_err(ServerFnError::from)
.map_err(|err| {
let msg = format!(
"Couldn't get item from Dynamo: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})
}
/// Gets and flattens all of a student's information from the database, regardless of their defined
/// sort key.
///
///
/// Note: if sorted data from the `put_student_data` function contains the same fields, this
/// function will have unexpected behavior. For example, if a `first_name` field appears in multiple
/// locations in the database, all for this single student, they will overwrite each other in no
/// specific order.
#[server]
pub async fn get_all_student_data(
subject: String
) -> Result<std::collections::HashMap<String, crate::common::ValueType>, ServerFnError> {
subject: String,
) -> Result<HashMap<String, crate::common::ValueType>, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
client.query()
client
.query()
.table_name(MAIN_TABLE_NAME)
.key_condition_expression("HK = :hk")
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{}", subject)))
@@ -105,10 +119,305 @@ pub async fn get_all_student_data(
let Some(items) = output.items else {
return HashMap::new();
};
items.iter().flatten()
.map(|(|k, v)| (k.clone(), ValueType::from(v)))
items
.iter()
.flatten()
.map(|(k, v)| (k.clone(), ValueType::from(v)))
.collect::<HashMap<String, ValueType>>()
})
.map_err(ServerFnError::from)
.map_err(|err| {
let msg = format!(
"Couldn't get data from Dynamo: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})
}
#[server]
pub async fn get_completed_students(
access_token: String,
) -> Result<HashMap<String, HashMap<String, crate::common::ValueType>>, 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
.scan()
.table_name(MAIN_TABLE_NAME)
.send()
.await
.map_err(|err| {
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})?;
let Some(items) = response.items else {
return Ok(HashMap::new());
};
let mut output = HashMap::<String, HashMap<String, ValueType>>::new();
items.into_iter().for_each(|mut form_info| {
let student_id_full = form_info
.get("HK")
.map(|v| v.as_s().cloned().unwrap_or_default())
.unwrap_or_default()
.to_owned();
let student_id = student_id_full.split("STUDENT#").collect::<String>();
form_info.remove("HK");
form_info.remove("SK");
let form_info_convert = form_info
.into_iter()
.map(|(k, v)| (k, ValueType::from(&v)))
.collect::<HashMap<String, ValueType>>();
// We want to insert all the remaining information into the output map
output
.entry(student_id)
.and_modify(|v| {
v.extend(form_info_convert.clone());
})
.or_insert(form_info_convert);
});
// Don't love this, but it does verify that the student has completed the demographics form
let output = output
.into_iter()
.filter_map(|(k, student_info)| {
student_info
.get("first_name")
.and_then(|_| Some((k, student_info.clone())))
})
.collect();
Ok(output)
}
/// # Get File by Key API
/// This function gets a file from the corresponding S3 file key. If the subject found from the
/// `access_token` does not match the owner of the requested file, the requesting user must have
/// provider-level access or higher.
///
/// ## Possible errors
/// Please check the [`validate_and_get_token_info`] function for all token parsing errors. Otherwise,
/// if the requesting user does not have the correct permissions, it will return a message indicating
/// such. If the file is not found, an error containing "File not found" will be returned.
#[server]
pub async fn get_file_by_key(
access_token: String,
file_key: String,
) -> Result<Vec<u8>, ServerFnError> {
use imports::*;
let user_claims = validate_and_get_token_info(access_token).await?;
// Check if the user's subject is contained in the file_key (since all files are keyed by the
// user's subject)
if !file_key.contains(&user_claims.subject) {
// Check that the user is a provider.
if !user_claims
.groups
.contains(&"ScholarshipProviders".to_string())
{
return Err(ServerFnError::new(
"Access denied: user is not the student or a provider",
));
}
}
// We now just need to get the actual file.
let client = aws_sdk_s3::Client::new(&create_aws_config().await);
let result = client
.get_object()
.key(file_key)
.send()
.await
.map_err(|e| {
let msg = e.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})?;
let bytes = result.body.collect().await?;
Ok(bytes.to_vec())
}
/// # Get Student File Names API
/// Gets a list of file names given a student's ID and the corresponding input ID from the forms.
/// These file names can be used to construct a file key for S3.
///
/// This API is only accessible by provider-level users.
#[server]
pub async fn get_student_files(
access_token: String,
student_id: String,
form_name: String,
question_id: String,
) -> Result<Vec<u8>, ServerFnError> {
use imports::*;
let claims = validate_and_get_token_info(access_token).await?;
// Check if the user has permission to get these files.
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 file_keys = client
.query()
.table_name(MAIN_TABLE_NAME)
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}")))
.expression_attribute_values(
":sk",
AttributeValue::S(format!("FILE#{form_name}#{question_id}")),
)
.key_condition_expression("HK = :hk and begins_with(SK, :sk)")
.send()
.await
.map_err(|e| {
let msg = e.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})
.map(|output| {
let Some(items) = output.items else {
return Vec::new();
};
items
.into_iter()
.filter_map(|item| item.get("file_key")?.as_s().ok().cloned())
.collect::<Vec<String>>()
})?;
debug_log!("Await student file futures...");
let futures: Vec<_> = file_keys
.into_iter()
.map(|file_key| async move {
debug_log!("Requesting file from API with key {file_key}");
// Get all files from S3. This is a batch operation.
let s3_client = aws_sdk_s3::Client::new(&create_aws_config().await);
let file_output = s3_client
.get_object()
.bucket("leptos-scholarships")
.key(&file_key)
.send()
.await
.ok()?;
let bytes = file_output.body.collect().await.ok()?.to_vec();
Some((file_key, bytes))
})
.collect();
let results = futures::future::join_all(futures)
.await
.into_iter()
.flatten()
.collect::<Vec<(String, Vec<u8>)>>();
let cur = std::io::Cursor::new(Vec::new());
let mut writer = zip::ZipWriter::new(cur);
debug_log!("Writing {} files to zip file...", results.len());
for (key, file_bytes) in results {
let file_name = key.split('/').last().unwrap_or_default();
writer
.start_file(file_name, SimpleFileOptions::default())
.map_err(ServerFnError::new)?;
writer.write_all(&file_bytes).map_err(ServerFnError::new)?;
}
debug_log!("Getting finished file");
let finished = writer.finish().map_err(ServerFnError::new)?;
Ok(finished.into_inner())
}
/// # Get All Input Files API
/// Gets all files that have been uploaded to the given input. For example, getting all the FAFSA
/// files that were uploaded to the FAFSA file input on the Financial Info page.
///
/// Returns a `HashMap` keyed by the submitting user's ID, with a value of all files that were
/// uploaded by that user.
///
/// This is only usable by users with provider-level access.
#[server]
pub async fn get_all_input_files(
access_token: String,
form_name: String,
input_name: String,
) -> Result<HashMap<String, Vec<String>>, 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
.scan()
.table_name(MAIN_TABLE_NAME)
.filter_expression("begins_with(SK, :sk)")
.expression_attribute_values(
":sk",
AttributeValue::S(format!("FILE#{form_name}#{input_name}")),
)
.send()
.await
.map_err(|e| {
let msg = e.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})?;
let mut result_map = HashMap::<String, Vec<String>>::new();
res.items.unwrap_or_default().iter().for_each(|item| {
let id = item
.get("HK")
.map(|v| v.as_s().cloned().unwrap_or_default())
.unwrap_or_default()
.split("STUDENT#")
.collect::<String>();
let file_key = item
.get("file_key")
.map(|v| v.as_s().cloned().unwrap_or_default())
.unwrap_or_default();
result_map
.entry(id)
.and_modify(|v| v.push(file_key.clone()))
.or_insert(vec![file_key]);
});
Ok(result_map)
}
+15 -16
View File
@@ -1,19 +1,16 @@
#[cfg(feature = "ssr")]
use jsonwebtoken::{jwk::JwkSet, DecodingKey};
use crate::common::UserClaims;
#[cfg(feature = "ssr")]
use jsonwebtoken::{DecodingKey, jwk::JwkSet};
use leptos::prelude::*;
use crate::common::UserClaims;
#[cfg(feature = "ssr")]
pub async fn fetch_jwks(user_pool_id: String, region: String) -> Result<JwkSet, ServerFnError> {
let request_url = format!(
"https://cognito-idp.{}.amazonaws.com/{}/.well-known/jwks.json",
region,
user_pool_id
region, user_pool_id
);
let jwks = reqwest::get(&request_url).await?
.json::<JwkSet>()
.await?;
let jwks = reqwest::get(&request_url).await?.json::<JwkSet>().await?;
Ok(jwks)
}
@@ -21,7 +18,7 @@ pub async fn fetch_jwks(user_pool_id: String, region: String) -> Result<JwkSet,
/// Validates a JWT, and, if successful, decodes it into a series of user claims.
#[cfg(feature = "ssr")]
pub async fn validate_and_get_token_info(token: String) -> Result<UserClaims, ServerFnError> {
use jsonwebtoken::{decode, Algorithm, Validation};
use jsonwebtoken::{Algorithm, Validation, decode};
let jwks = fetch_jwks("us-east-1_Lfjuy5zaM".into(), "us-east-1".into()).await?;
@@ -31,16 +28,18 @@ pub async fn validate_and_get_token_info(token: String) -> Result<UserClaims, Se
let header = jsonwebtoken::decode_header(&token)
.map_err(|e| ServerFnError::new(format!("Invalid token header: {e}")))?;
let kid = header.kid.ok_or_else(|| ServerFnError::new("Missing kid header"))?;
let kid = header
.kid
.ok_or_else(|| ServerFnError::new("Missing kid header"))?;
let jwk = jwks.find(&kid)
let jwk = jwks
.find(&kid)
.ok_or_else(|| ServerFnError::new("Couldn't find matching key in JWKS"))?;
let token_data = decode::<UserClaims>(
&token,
&DecodingKey::from_jwk(&jwk)?,
&validation
).map_err(|_| ServerFnError::new("Invalid token found".to_string()))?;
leptos::logging::debug_log!("Parsing token: {:?}", token);
let token_data = decode::<UserClaims>(&token, &DecodingKey::from_jwk(&jwk)?, &validation)
.map_err(|err| ServerFnError::new(format!("Invalid token found: {err}")))?;
if token_data.claims.token_use != "access" {
return Err(ServerFnError::new("Token type mismatch"));
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -40,7 +40,8 @@ pub fn StudentAthleticsPage() -> impl IntoView {
"Lacrosse",
"Softball",
"Tennis",
"Baseball"
"Baseball",
"Volleyball"
]
),
input!(
+13
View File
@@ -0,0 +1,13 @@
use leptos::prelude::*;
#[component]
pub fn ClosedForm() -> impl IntoView {
view! {
<div class="flex flex-col items-center mx-auto mt-10">
<h1 class="text-2xl font-bold">"Form Closed"</h1>
<p class="text-lg">
"This form is now closed. If you believe this is an error, please contact the guidance office."
</p>
</div>
}
}
+39 -28
View File
@@ -4,7 +4,7 @@ use crate::pages::api::files::list_files;
use crate::pages::api::students::get_all_student_data;
use crate::pages::api::{get_all_scholarship_info, get_comparison_info};
use crate::utils::get_user_claims;
use leptos::logging::debug_log;
use leptos::logging::{debug_error, debug_log};
use leptos::prelude::*;
use leptos_oidc::AuthSignal;
@@ -23,6 +23,7 @@ pub fn StudentEligibilityPage() -> impl IntoView {
let auth = expect_context::<AuthSignal>();
let user_claims = get_user_claims();
let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone()));
let access_token = Memo::new(move |_| auth.get().authenticated().map(|a| a.access_token()));
let form_id = StoredValue::new("scholarship_essays".to_string());
@@ -63,8 +64,7 @@ pub fn StudentEligibilityPage() -> impl IntoView {
.get("name")
.unwrap_or(&ValueType::String(None))
.as_string()
.ok()
.flatten()
.ok()?
.unwrap_or_default();
debug_log!("Checking scholarship: {}", scholarship_name);
let requirements_map = scholarship
@@ -72,8 +72,7 @@ pub fn StudentEligibilityPage() -> impl IntoView {
.get("requirements")
.unwrap_or(&ValueType::Map(None))
.as_map()
.ok()
.flatten()
.ok()?
.unwrap_or_default()
.values()
.cloned()
@@ -104,38 +103,50 @@ pub fn StudentEligibilityPage() -> impl IntoView {
debug_log!(
"Resolved requirements: {:?}", resolved_requirements.iter().flatten().map(|rel| rel.display_text.clone()).collect::<Vec<String>>()
);
let valid = resolved_requirements
.iter()
.all(|list| {
list.iter()
.fold(
false,
|prev, requirement| {
let result = requirement
.compare(&student_info)
.unwrap_or(false);
if prev { prev } else { result }
},
)
});
let valid = !scholarship_name.is_empty()
&& resolved_requirements
.iter()
.all(|list| {
debug_log!("Checking list: {:?}", list);
if list.is_empty() {
true
} else {
list.iter()
.any(|requirement| {
let result = requirement.compare(&student_info);
match result.as_ref() {
Ok(status) => {
debug_log!(
"Requirement with id {} is Ok({})", requirement.id, status
);
status.clone()
}
Err(e) => {
debug_error!(
"Requirement with id {} failed: {}", requirement.id, e
);
false
}
}
})
}
});
if valid {
debug_log!("Scholarship is valid.");
let scholarship_id = StoredValue::new(scholarship.subject.clone());
let resource = Resource::new(
move || (
auth
.try_with(|a| a.authenticated().map(|a| a.access_token()))
.flatten(),
access_token.get(),
form_id.get_value(),
scholarship_id.get_value(),
),
async move |(access_token, form_id, scholarship_id)| {
list_files(
access_token.unwrap_or_default(),
form_id,
scholarship_id,
)
.await
let Some(access_token) = access_token else {
return Err(
ServerFnError::new("Couldn't find access token"),
);
};
list_files(access_token, form_id, scholarship_id).await
},
);
Some((scholarship.clone(), resource))
+1 -1
View File
@@ -12,7 +12,7 @@ pub fn StudentExtracurricularsPage() -> impl IntoView {
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm
title="Extracurricular Information"
title="Extracurricular and Community Service Information"
description="This information will allow you to be eligible for any scholarships that have specific extracurricular requirements."
on_submit=controller.submit_action
disabled=controller.submit_pending
+3 -3
View File
@@ -1,11 +1,11 @@
use leptos::prelude::*;
use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm};
use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component]
pub fn StudentFamilyPage() -> impl IntoView {
let controller = use_student_form("family", true);
view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" />
@@ -115,4 +115,4 @@ pub fn StudentFamilyPage() -> impl IntoView {
<div class="flex flex-1" />
</Show>
}
}
}
+25 -24
View File
@@ -1,10 +1,10 @@
use std::collections::HashMap;
use leptos::prelude::*;
use uuid::Uuid;
use crate::common::ValueType;
use crate::components::{Toast, ToastContext};
use crate::pages::api::students::{get_student_data, PutStudentData};
use crate::pages::api::students::{PutStudentData, get_student_data};
use crate::utils::get_user_claims;
use leptos::prelude::*;
use std::collections::HashMap;
use uuid::Uuid;
pub struct StudentFormInfo {
pub data_map: RwSignal<HashMap<String, ValueType>>,
@@ -20,22 +20,21 @@ pub fn use_student_form(
enable_toasts: bool,
) -> StudentFormInfo {
let form_type = form_type.into();
let user_claims = get_user_claims();
let user_id = Memo::new(move |_| {
user_claims.get()
.map(|info| info.claims.subject.clone())
});
let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone()));
let data_map = RwSignal::new(HashMap::new());
let refresh_trigger = Trigger::new();
let data_resource = Resource::new(
move || (user_id.get().unwrap_or_default(), form_type.get(), refresh_trigger.track()),
async move |(id, form_type, _)| get_student_data(
id,
form_type,
).await
move || (user_id.get(), form_type.get(), refresh_trigger.track()),
async move |(id, form_type, _)| {
let Some(id) = id else {
return Ok(HashMap::new());
};
get_student_data(id, form_type).await
},
);
Effect::new(move || {
@@ -50,13 +49,15 @@ pub fn use_student_form(
let submit_action = ServerAction::<PutStudentData>::new();
let on_submit = move || {
submit_action.dispatch(PutStudentData {
subject: user_id.get().unwrap_or_default(),
data_type: form_type.get(),
data_map: data_map.get()
});
if let Some(id) = user_id.get() {
submit_action.dispatch(PutStudentData {
subject: id,
data_type: form_type.get(),
data_map: data_map.get(),
});
}
};
if enable_toasts {
let mut toasts = expect_context::<ToastContext>();
Effect::watch(
@@ -74,13 +75,13 @@ pub fn use_student_form(
Err(e) => Toast::new()
.id(Uuid::new_v4())
.header("Submission Failed")
.msg(e.to_string())
.msg(e.to_string()),
};
toasts.toast(toast);
untrack(move || submit_action.clear());
},
false
false,
);
}
@@ -90,6 +91,6 @@ pub fn use_student_form(
data_resource,
user_id,
submit_action: on_submit.into(),
submit_pending: submit_action.pending()
submit_pending: submit_action.pending(),
}
}
}
+4 -4
View File
@@ -1,6 +1,7 @@
mod academics_page;
mod additional_page;
mod athletics_page;
mod closed_form;
mod demographics_page;
mod eligibility_page;
mod extracurriculars_page;
@@ -9,14 +10,14 @@ mod financial_page;
mod form_setup;
mod home_page;
mod shell;
mod test_display;
mod specific_requirements_page;
mod university_page;
mod work_experience_page;
mod specific_requirements_page;
pub use academics_page::*;
pub use additional_page::*;
pub use athletics_page::*;
pub use closed_form::*;
pub use demographics_page::*;
pub use eligibility_page::*;
pub use extracurriculars_page::*;
@@ -24,7 +25,6 @@ pub use family_info_page::*;
pub use financial_page::*;
pub use home_page::*;
pub use shell::*;
pub use test_display::*;
pub use specific_requirements_page::*;
pub use university_page::*;
pub use work_experience_page::*;
pub use specific_requirements_page::*;
-77
View File
@@ -1,77 +0,0 @@
use crate::common::{SchemaNode, SchemaType};
use crate::components::{Loading, ValueDisplay};
use crate::pages::api::students::get_all_student_data;
use crate::utils::get_user_claims;
use leptos::prelude::*;
#[component]
pub fn StudentTestDisplay() -> impl IntoView {
let create_student_display_schema = move || {
SchemaNode::new(SchemaType::Map)
.header("Student Information")
.child(
"first_name",
SchemaNode::new(SchemaType::String).header("First Name"),
)
.child(
"last_name",
SchemaNode::new(SchemaType::String).header("Last Name"),
)
.child(
"sports_participation",
SchemaNode::new(SchemaType::List)
.header("Sports Activities")
.item_template(
SchemaNode::new(SchemaType::Map)
.header("Sport")
.child(
"sport_name",
SchemaNode::new(SchemaType::String).header("Sport Name"),
)
.child(
"achievements",
SchemaNode::new(SchemaType::String).header("Special Achievements"),
),
),
)
};
let user_claims = get_user_claims();
let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone()));
let resource = Resource::new(
move || user_id.get().unwrap_or_default(),
async move |id| get_all_student_data(id).await,
);
let schema = StoredValue::new(create_student_display_schema());
view! {
<div class="flex flex-1" />
<Suspense fallback=Loading>
<div class="flex flex-2 flex-col">
{move || {
resource
.get()
.map(|data_map| {
let Ok(map) = data_map else {
return view! {}.into_any();
};
let data_map = StoredValue::new(map);
view! {
<div>
<ValueDisplay
schema=schema.get_value()
data_map=data_map.get_value()
/>
</div>
}
.into_any()
})
}}
</div>
</Suspense>
<div class="flex flex-1" />
}
}