diff --git a/Cargo.lock b/Cargo.lock index 452cddb..38d7b35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2594,7 +2594,9 @@ dependencies = [ "anyhow", "chrono", "derive_builder", + "derive_more", "dotenvy", + "log", "reqwest", "reqwest-middleware", "reqwest-retry", @@ -2604,6 +2606,8 @@ dependencies = [ "serde_json", "serde_with", "tokio", + "tracing", + "tracing-subscriber", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8773ce0..1b737b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ workspace = { members = [ name = "scipio" version = "0.1.0" edition = "2021" +authors = ["Anish Sinha "] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/migrations/20240722170323_initial.up.sql b/migrations/20240722170323_initial.up.sql index 3941e3c..f557f75 100644 --- a/migrations/20240722170323_initial.up.sql +++ b/migrations/20240722170323_initial.up.sql @@ -29,6 +29,7 @@ language plpgsql; -- Allowed age ranges for volunteers (from Airtable) create type age_range as enum( + '17_and_under', '18-24', '25-29', '30-34', diff --git a/scipio-airtable/Cargo.toml b/scipio-airtable/Cargo.toml index 59afa0a..919a8a5 100644 --- a/scipio-airtable/Cargo.toml +++ b/scipio-airtable/Cargo.toml @@ -7,7 +7,9 @@ edition = "2021" anyhow = "1.0.89" chrono = { version = "0.4.38", features = ["serde"] } derive_builder = "0.20.2" +derive_more = { version = "1.0.0", features = ["full"] } dotenvy = "0.15.7" +log = "0.4.22" reqwest = { version = "0.12.8", default-features = false, features = [ "json", "rustls-tls", @@ -19,6 +21,8 @@ serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" serde_with = "3.11.0" tokio = { version = "1.40.0", features = ["full"] } +tracing = "0.1.40" +tracing-subscriber = "0.3.18" [dev-dependencies] rstest = "0.23.0" diff --git a/scipio-airtable/src/base_data/mod.rs b/scipio-airtable/src/base_data/mod.rs index a67ee88..10796ce 100644 --- a/scipio-airtable/src/base_data/mod.rs +++ b/scipio-airtable/src/base_data/mod.rs @@ -2,6 +2,3 @@ pub mod bases; pub mod entities; pub mod records; pub mod responses; - -#[cfg(test)] -mod tests; diff --git a/scipio-airtable/src/base_data/records.rs b/scipio-airtable/src/base_data/records.rs index 5912758..3f1cb8e 100644 --- a/scipio-airtable/src/base_data/records.rs +++ b/scipio-airtable/src/base_data/records.rs @@ -2,10 +2,11 @@ use std::fmt::Display; use anyhow::Result; use derive_builder::Builder; +use derive_more::derive::Display; use scipio_macros::ToQueryString; use serde::{Deserialize, Serialize}; -use super::responses::ListRecordsResponse; +use super::responses::{GetRecordResponse, ListRecordsResponse}; use crate::Airtable; /// A struct representing a sort query parameter. @@ -58,25 +59,78 @@ pub struct ListRecordsQuery { pub record_metadata: Option, } +#[derive(Debug, Serialize, Clone, Display)] +#[serde(rename_all = "snake_case")] +pub enum CellFormat { + #[display("json")] + Json, + #[display("string")] + String, +} + +#[derive(Debug, Serialize, Clone, Builder, ToQueryString)] +#[serde(rename_all = "camelCase")] +pub struct GetRecordQuery { + #[builder(setter(into), default)] + pub time_zone: Option, + #[builder(default, setter(into))] + pub user_locale: Option, + #[builder(setter(into), default)] + pub cell_format: Option, + #[builder(default, setter(into))] + pub return_fields_by_field_id: Option, +} + impl Airtable { pub async fn list_records( &self, base_id: &str, table_id: &str, - query: ListRecordsQuery, + query: Option<&ListRecordsQuery>, ) -> Result> where T: for<'de> Deserialize<'de>, { let url = format!( "https://api.airtable.com/v0/{base_id}/{table_id}/{query}", - base_id = base_id, - table_id = table_id, - query = query.to_query_string() + query = query.map(|q| q.to_query_string()).unwrap_or_default() ); let data = self.http.get(&url).send().await?.json::>().await?; Ok(data) } + + pub async fn get_record( + &self, + base_id: &str, + table_id: &str, + record_id: &str, + query: Option<&GetRecordQuery>, + ) -> Result> + where + T: for<'de> Deserialize<'de>, + { + let url = format!( + "https://api.airtable.com/v0/{base_id}/{table_id}/{record_id}/{query}", + query = query.map(|q| q.to_query_string()).unwrap_or_default() + ); + + let data = self.http.get(&url).send().await?.json::>().await?; + + Ok(data) + } + + pub async fn update_record( + &self, + base_id: &str, + table_id: &str, + record_id: &str, + data: T, + ) -> Result<()> + where + T: for<'de> Deserialize<'de>, + { + Ok(()) + } } diff --git a/scipio-airtable/src/base_data/responses.rs b/scipio-airtable/src/base_data/responses.rs index afffde7..1237c5c 100644 --- a/scipio-airtable/src/base_data/responses.rs +++ b/scipio-airtable/src/base_data/responses.rs @@ -27,3 +27,5 @@ pub struct ListRecordsResponse { pub records: Vec>, pub offset: Option, } + +pub type GetRecordResponse = Record; diff --git a/scipio-airtable/src/base_data/tests/fixtures.rs b/scipio-airtable/src/base_data/tests/fixtures.rs deleted file mode 100644 index 4623933..0000000 --- a/scipio-airtable/src/base_data/tests/fixtures.rs +++ /dev/null @@ -1,12 +0,0 @@ -use std::env; - -use rstest::fixture; - -use crate::Airtable; - -#[fixture] -pub fn airtable() -> Airtable { - dotenvy::dotenv().expect("error loading environment variables"); - let api_token = env::var("AIRTABLE_API_TOKEN").expect("missing AIRTABLE_API_TOKEN variable"); - Airtable::new(&api_token, 5).expect("error creating Airtable client") -} diff --git a/scipio-airtable/src/base_data/tests/mod.rs b/scipio-airtable/src/base_data/tests/mod.rs deleted file mode 100644 index 1e83b21..0000000 --- a/scipio-airtable/src/base_data/tests/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod bases; -mod fixtures; -mod records; diff --git a/scipio-airtable/src/base_data/tests/records.rs b/scipio-airtable/src/base_data/tests/records.rs deleted file mode 100644 index 77fb34f..0000000 --- a/scipio-airtable/src/base_data/tests/records.rs +++ /dev/null @@ -1,51 +0,0 @@ -use anyhow::Result; -use rstest::rstest; -use serde::Deserialize; - -use super::fixtures::airtable; -use crate::base_data::records::ListRecordsQueryBuilder; -use crate::Airtable; - -#[cfg(feature = "integration")] -#[rstest] -#[tokio::test] -pub async fn test_list_records(airtable: Airtable) -> Result<()> { - #[allow(unused)] - #[derive(Debug, Deserialize)] - #[serde(rename_all = "PascalCase")] - struct SubsetFields { - first_name: String, - last_name: String, - email: String, - phone: Option, - } - - #[allow(unused)] - #[derive(Debug, Deserialize, Clone)] - #[serde(rename_all = "PascalCase")] - pub struct MentorMenteeLinkage { - #[serde(rename = "Email")] - pub mentor_email: String, - #[serde(rename = "Mentee Email (from Volunteers)", default)] - pub mentee_email: Vec, - } - - let query = ListRecordsQueryBuilder::default() - .fields( - // ["FirstName", "LastName", "Email", "Phone"] - ["Email", "Mentee Email (from Volunteers)"] - .iter() - .map(ToString::to_string) - .collect::>(), - ) - .view("viwifNGQcjzu4d3wQ".to_owned()) - .build()?; - - let res = airtable - .list_records::("appS5z0uqz4l0IJvP", "tblJfJ4klx6tXPLCQ", query) - .await?; - - dbg!(&res.records); - - Ok(()) -} diff --git a/scipio-airtable/src/lib.rs b/scipio-airtable/src/lib.rs index 6f3aa77..9f4cfcc 100644 --- a/scipio-airtable/src/lib.rs +++ b/scipio-airtable/src/lib.rs @@ -1,5 +1,7 @@ pub mod base_data; mod retry; +#[cfg(test)] +mod tests; use anyhow::Result; use reqwest::header::{self, HeaderMap, HeaderValue}; @@ -9,6 +11,7 @@ use reqwest_retry::policies::ExponentialBackoff; use reqwest_retry::RetryTransientMiddleware; use retry::DefaultRetryStrategy; +#[derive(Clone)] pub struct Airtable { http: ClientWithMiddleware, } diff --git a/scipio-airtable/src/base_data/tests/bases.rs b/scipio-airtable/src/tests/bases.rs similarity index 74% rename from scipio-airtable/src/base_data/tests/bases.rs rename to scipio-airtable/src/tests/bases.rs index 02652a6..53d69b6 100644 --- a/scipio-airtable/src/base_data/tests/bases.rs +++ b/scipio-airtable/src/tests/bases.rs @@ -1,13 +1,12 @@ use rstest::rstest; -use super::fixtures::airtable; -use crate::Airtable; +use super::fixtures::{context, AsyncTestContext}; #[cfg(feature = "integration")] #[rstest] #[tokio::test] -pub async fn test_list_bases(airtable: Airtable) { - let bases_response = airtable.list_bases(None).await.unwrap(); +pub async fn test_list_bases(context: AsyncTestContext) { + let bases_response = context.airtable.list_bases(None).await.unwrap(); dbg!(&bases_response); } diff --git a/scipio-airtable/src/tests/fixtures.rs b/scipio-airtable/src/tests/fixtures.rs new file mode 100644 index 0000000..70cb309 --- /dev/null +++ b/scipio-airtable/src/tests/fixtures.rs @@ -0,0 +1,53 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::{env, thread}; + +use anyhow::Result; +use rstest::fixture; +use tokio::runtime::Runtime; +use tokio::sync::Mutex; + +use crate::Airtable; + +type BoxedAsyncFn = Box Pin> + Send>> + Send + Sync>; + +pub struct AsyncTestContext { + pub airtable: Airtable, + pub cleanup: Arc>>, +} + +impl Drop for AsyncTestContext { + fn drop(&mut self) { + let cleanup = self.cleanup.clone(); + let handle = thread::spawn(move || { + Runtime::new().expect("error creating runtime to handle cleanup").block_on(async move { + let mut error_count = 0; + for cleanup_fn in cleanup.lock().await.iter() { + if let Err(err) = cleanup_fn().await { + error_count += 1; + log::error!("Error during cleanup: {:?}", err); + } + } + if error_count > 0 { + panic!("{} cleanup functions failed", error_count); + } + }) + }); + + handle.join().expect("error joining cleanup thread"); + } +} + +#[fixture] +pub fn context() -> AsyncTestContext { + dotenvy::dotenv().expect("error loading environment variables"); + tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).init(); + let api_token = + env::var("TEST_AIRTABLE_API_TOKEN").expect("missing TEST_AIRTABLE_API_TOKEN variable"); + let client = Airtable::new(&api_token, 5).expect("error creating Airtable client"); + + log::info!("Creating async test context"); + + AsyncTestContext { airtable: client, cleanup: Arc::new(Mutex::new(vec![])) } +} diff --git a/scipio-airtable/src/tests/mod.rs b/scipio-airtable/src/tests/mod.rs new file mode 100644 index 0000000..63a11f6 --- /dev/null +++ b/scipio-airtable/src/tests/mod.rs @@ -0,0 +1,52 @@ +mod bases; +mod fixtures; +mod records; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Customer { + #[serde(rename = "Record Id")] + pub record_id: String, + #[serde(rename = "Customer Id")] + pub customer_id: String, + #[serde(rename = "First Name")] + pub first_name: String, + #[serde(rename = "Last Name")] + pub last_name: String, + #[serde(rename = "Company")] + pub company: String, + #[serde(rename = "City")] + pub city: String, + #[serde(rename = "Country")] + pub country: String, + #[serde(rename = "Phone 1")] + pub phone_1: String, + #[serde(rename = "Phone 2")] + pub phone_2: String, + #[serde(rename = "Email")] + pub email: String, + #[serde(rename = "Subscription Date")] + pub subscription_date: String, + #[serde(rename = "Website")] + pub website: String, +} + +impl Customer { + pub fn field_names() -> Vec<&'static str> { + vec![ + "Record Id", + "Customer Id", + "First Name", + "Last Name", + "Company", + "City", + "Country", + "Phone 1", + "Phone 2", + "Email", + "Subscription Date", + "Website", + ] + } +} diff --git a/scipio-airtable/src/tests/records.rs b/scipio-airtable/src/tests/records.rs new file mode 100644 index 0000000..4a8217d --- /dev/null +++ b/scipio-airtable/src/tests/records.rs @@ -0,0 +1,83 @@ +use std::env; + +use anyhow::Result; +use rstest::rstest; + +use super::fixtures::{context, AsyncTestContext}; +use super::Customer; +use crate::base_data::records::{GetRecordQueryBuilder, ListRecordsQueryBuilder}; + +#[cfg(feature = "integration")] +#[rstest] +#[tokio::test] +pub async fn test_list_records(context: AsyncTestContext) -> Result<()> { + let mut cleanup = context.cleanup.lock().await; + cleanup.push(Box::new(|| { + Box::pin(async move { + println!("Cleaning up test_list_records"); + Ok(()) + }) + })); + + let base = env::var("TEST_AIRTABLE_API_BASE").expect("missing TEST_AIRTABLE_BASE variable"); + let table = env::var("TEST_AIRTABLE_API_TABLE").expect("missing TEST_AIRTABLE_TABLE variable"); + let view = env::var("TEST_AIRTABLE_API_VIEW").expect("missing TEST_AIRTABLE_VIEW variable"); + + let query = ListRecordsQueryBuilder::default() + .fields(Customer::field_names().iter().map(ToString::to_string).collect::>()) + .view(view) + .build()?; + + let res = context.airtable.list_records::(&base, &table, Some(&query)).await?; + + dbg!(&res.records); + + Ok(()) +} + +#[cfg(feature = "integration")] +#[rstest] +#[tokio::test] +pub async fn test_get_record(context: AsyncTestContext) -> Result<()> { + use anyhow::bail; + + let mut cleanup = context.cleanup.lock().await; + cleanup.push(Box::new(|| { + Box::pin(async move { + log::info!("Cleaning up test_get_record"); + bail!("test_get_record failed"); + Ok(()) + }) + })); + + let base = env::var("TEST_AIRTABLE_API_BASE").expect("missing TEST_AIRTABLE_BASE variable"); + let table = env::var("TEST_AIRTABLE_API_TABLE").expect("missing TEST_AIRTABLE_TABLE variable"); + let record_id = + env::var("TEST_AIRTABLE_API_RECORD_ID").expect("missing TEST_AIRTABLE_RECORD_ID variable"); + + let query = GetRecordQueryBuilder::default().build()?; + + let res = + context.airtable.get_record::(&base, &table, &record_id, Some(&query)).await?; + + dbg!(&res); + + Ok(()) +} + +#[cfg(feature = "integration")] +#[rstest] +#[tokio::test] +pub async fn test_update_record(context: AsyncTestContext) -> Result<()> { + let mut cleanup = context.cleanup.lock().await; + let airtable = context.airtable.clone(); + cleanup.push(Box::new(move || { + let airtable = airtable.clone(); + Box::pin(async move { + log::info!("Cleaning up test_get_record"); + Ok(()) + }) + })); + + Ok(()) +} diff --git a/scipio-macros/Cargo.toml b/scipio-macros/Cargo.toml index 255a096..2aeb992 100644 --- a/scipio-macros/Cargo.toml +++ b/scipio-macros/Cargo.toml @@ -11,7 +11,7 @@ proc-macro = true [dependencies] proc-macro2 = "1.0.86" quote = "1.0.36" -syn = { version = "2.0.72", features = ["extra-traits"] } +syn = { version = "2.0.72", features = ["full"] } [dev-dependencies] trybuild = "1.0.99" diff --git a/scipio-macros/src/lib.rs b/scipio-macros/src/lib.rs index d726e00..7405e5c 100644 --- a/scipio-macros/src/lib.rs +++ b/scipio-macros/src/lib.rs @@ -1,6 +1,9 @@ use proc_macro::TokenStream; use quote::quote; -use syn::{parse_macro_input, Data, DeriveInput, Fields, GenericArgument, PathArguments, Type}; +use syn::{ + parse_macro_input, Data, DeriveInput, Fields, GenericArgument, Ident, ItemStruct, + PathArguments, Type, +}; const SUPPORTED_OPTION_TYPES: [&str; 5] = [ "Option", @@ -13,11 +16,7 @@ const SUPPORTED_OPTION_TYPES: [&str; 5] = [ fn unwrap_type_twice(ty: &Type) -> (std::option::Option, std::option::Option) { if let Type::Path(type_path) = ty { let raw_first_segment_path = type_path.path.segments.iter().fold( - if type_path.path.leading_colon.is_some() { - "::".to_string() - } else { - "".to_string() - }, + if type_path.path.leading_colon.is_some() { "::".to_string() } else { "".to_string() }, |acc, el| acc + &el.ident.to_string() + "::", ); @@ -135,3 +134,50 @@ pub fn to_query_string_derive(input: TokenStream) -> TokenStream { TokenStream::from(expanded) } + +#[proc_macro_derive(Partial)] +pub fn derive_partial(input: TokenStream) -> TokenStream { + // Parse the input token stream as a DeriveInput (struct definition) + let input = parse_macro_input!(input as DeriveInput); + + // Extract the name of the struct + let struct_name = &input.ident; + + // Generate a new struct name by prepending "Partial" to the original struct name + let partial_struct_name = + syn::Ident::new(&format!("Partial{}", struct_name), struct_name.span()); + + // Generate field wrapping in Option (for each field in the original struct) + let fields = match input.data { + Data::Struct(ref data_struct) => { + match data_struct.fields { + Fields::Named(ref fields) => { + // Map each field to Option + fields + .named + .iter() + .map(|f| { + let name = &f.ident; + let ty = &f.ty; + quote! { + pub #name: Option<#ty> + } + }) + .collect::>() + } + _ => panic!("Partial can only be derived for structs with named fields"), + } + } + _ => panic!("Partial can only be derived for structs"), + }; + + // Generate the new struct definition with Option fields + let expanded = quote! { + // Define the new struct with Option-wrapped fields + pub struct #partial_struct_name { + #(#fields),* + } + }; + + TokenStream::from(expanded) +} diff --git a/scipio-macros/tests/test_partial.rs b/scipio-macros/tests/test_partial.rs new file mode 100644 index 0000000..9ef83c6 --- /dev/null +++ b/scipio-macros/tests/test_partial.rs @@ -0,0 +1,9 @@ +use scipio_macros::Partial; + +#[derive(Partial)] +struct S { + pub field: String, + pub direction: String, +} + +fn main() {} diff --git a/src/app/api/v1/authz/mod.rs b/src/app/api/v1/authz/mod.rs index 43a3c23..e431c43 100644 --- a/src/app/api/v1/authz/mod.rs +++ b/src/app/api/v1/authz/mod.rs @@ -26,7 +26,7 @@ pub struct AuthzApi; /// /// * `ctx`: The application context pub async fn build(ctx: Arc) -> Router<()> { - let basic_guard = make_rbac(vec![]).await; + let guard = make_rbac(vec![]).await; let user = routing::post(controllers::user); let permissions = routing::post(controllers::permissions); @@ -34,6 +34,6 @@ pub async fn build(ctx: Arc) -> Router<()> { Router::new() .route("/user", user) .route("/permissions", permissions) - .route_layer(from_fn_with_state(ctx.clone(), basic_guard)) + .route_layer(from_fn_with_state(ctx.clone(), guard)) .with_state(ctx.clone()) } diff --git a/src/app/api/v1/cycles/mod.rs b/src/app/api/v1/cycles/mod.rs index d3f5356..9461c8d 100644 --- a/src/app/api/v1/cycles/mod.rs +++ b/src/app/api/v1/cycles/mod.rs @@ -27,16 +27,16 @@ pub struct CyclesApi; /// /// * `ctx`: The application context pub async fn build(ctx: Arc) -> Router<()> { - let guard1 = make_rbac(vec!["read:cycles".to_owned()]).await; - let guard2 = make_rbac(vec!["delete:cycles".to_owned()]).await; + let read_cycles_guard = make_rbac(vec!["read:cycles".to_owned()]).await; + let write_cycles_guard = make_rbac(vec!["delete:cycles".to_owned()]).await; let fetch_cycles = routing::get(controllers::fetch_cycles); let delete_cycle = routing::delete(controllers::delete_cycle); Router::new() .route("/", fetch_cycles) - .route_layer(from_fn_with_state(ctx.clone(), guard1)) + .route_layer(from_fn_with_state(ctx.clone(), read_cycles_guard)) .route("/:id", delete_cycle) - .route_layer(from_fn_with_state(ctx.clone(), guard2)) + .route_layer(from_fn_with_state(ctx.clone(), write_cycles_guard)) .with_state(ctx.clone()) } diff --git a/src/app/api/v1/data_exports/mod.rs b/src/app/api/v1/data_exports/mod.rs index 6f24231..fc6d3b0 100644 --- a/src/app/api/v1/data_exports/mod.rs +++ b/src/app/api/v1/data_exports/mod.rs @@ -45,13 +45,12 @@ pub struct DataExportsApi; /// /// * `ctx`: The application context pub async fn build(ctx: Arc) -> Router<()> { - let guard1 = make_rbac(vec![]).await; + let export_workspace_guard = make_rbac(vec!["export:volunteers-workspace".to_owned()]).await; let export_users_to_workspace = routing::post(controllers::export_users_to_workspace); Router::new() .route("/:project_cycle_id/workspace", export_users_to_workspace) - // .route("/x", routing::post(workspace::c)) - .route_layer(from_fn_with_state(ctx.clone(), guard1)) + .route_layer(from_fn_with_state(ctx.clone(), export_workspace_guard)) .with_state(ctx.clone()) } diff --git a/src/app/api/v1/data_exports/workspace/mod.rs b/src/app/api/v1/data_exports/workspace/mod.rs index 20b0f44..d3848ff 100644 --- a/src/app/api/v1/data_exports/workspace/mod.rs +++ b/src/app/api/v1/data_exports/workspace/mod.rs @@ -50,8 +50,8 @@ fn process_volunteers(params: &ExportParams) -> Result { OnboardingEmailParamsBuilder::default() .first_name(workspace_user.first_name.clone()) .last_name(workspace_user.last_name.clone()) - // .email(workspace_user.recovery_email.clone()) - .email("anish@developforgood.org") + .email(workspace_user.recovery_email.clone()) + // .email("anish@developforgood.org") .workspace_email(workspace_user.primary_email.clone()) .temporary_password(workspace_user.password.clone()) .build()?, diff --git a/src/app/api/v1/data_imports/controllers.rs b/src/app/api/v1/data_imports/controllers.rs index 915b4e0..2655402 100644 --- a/src/app/api/v1/data_imports/controllers.rs +++ b/src/app/api/v1/data_imports/controllers.rs @@ -57,6 +57,7 @@ pub async fn import_airtable_base( let storage_layer = &services.storage_layer; if !services.airtable.validate_schema(&base_id).await? { + log::error!("Invalid schema for airtable base"); return Ok(api_response::error( StatusCode::BAD_REQUEST, "Invalid schema for airtable base", diff --git a/src/app/api/v1/data_imports/mod.rs b/src/app/api/v1/data_imports/mod.rs index 7c00d5a..3014987 100644 --- a/src/app/api/v1/data_imports/mod.rs +++ b/src/app/api/v1/data_imports/mod.rs @@ -33,14 +33,16 @@ impl FromRef> for ImportServices { } pub async fn build(ctx: Arc) -> Router<()> { - let guard1 = make_rbac(vec!["read:available-bases".to_owned()]).await; + let read_guard = make_rbac(vec!["read:available-bases".to_owned()]).await; + // let import_guard = make_rbac(vec!["import:available-bases".to_owned()]).await; let import_airtable_base = routing::post(controllers::import_airtable_base); let list_available_airtable_bases = routing::get(controllers::list_available_airtable_bases); Router::new() - .route("/airtable/base/:base_id", import_airtable_base) .route("/airtable/available-bases", list_available_airtable_bases) - .route_layer(from_fn_with_state(ctx.clone(), guard1)) + .route("/airtable/base/:base_id", import_airtable_base) + .route_layer(from_fn_with_state(ctx.clone(), read_guard)) + // .route_layer(from_fn_with_state(ctx.clone(), import_guard)) .with_state(ctx.clone()) } diff --git a/src/app/api/v1/stats/mod.rs b/src/app/api/v1/stats/mod.rs index a426ac8..6df4a6e 100644 --- a/src/app/api/v1/stats/mod.rs +++ b/src/app/api/v1/stats/mod.rs @@ -20,12 +20,12 @@ use crate::app::state::Services; pub struct StatsApi; pub async fn build(ctx: Arc) -> Router<()> { - let guard1 = make_rbac(vec![]).await; + let read_guard = make_rbac(vec!["read:stats".to_owned()]).await; let fetch_basic_stats = routing::get(controllers::fetch_basic_stats); Router::new() .route("/:project_cycle_id/basic", fetch_basic_stats) - .route_layer(from_fn_with_state(ctx.clone(), guard1)) + .route_layer(from_fn_with_state(ctx.clone(), read_guard)) .with_state(ctx.clone()) } diff --git a/src/main.rs b/src/main.rs index c9824fc..042d3f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,8 @@ // @author Anish Sinha // ──────────────────────────────────────────────────────────────────────────────────────────────────── +#![forbid(unsafe_code)] + mod app; mod cli; mod services; @@ -44,13 +46,13 @@ use crate::cli::Args; #[tokio::main] async fn main() -> Result<()> { + tracing_subscriber::fmt().with_max_level(tracing::Level::DEBUG).init(); + match dotenvy::dotenv() { - Ok(_) => println!("loaded .env file"), - Err(_) => println!("no .env file found"), + Ok(_) => log::info!("loaded .env file"), + Err(_) => log::info!("no .env file found"), }; - tracing_subscriber::fmt().with_max_level(tracing::Level::DEBUG).init(); - let args = Args::parse(); let addr = format!("{}:{}", args.host, args.port); diff --git a/src/services/airtable/entities.rs b/src/services/airtable/entities.rs index cbc4c13..99b2b00 100644 --- a/src/services/airtable/entities.rs +++ b/src/services/airtable/entities.rs @@ -9,7 +9,7 @@ use crate::services::storage::types::{ }; use crate::services::storage::volunteers::CreateVolunteer; -#[derive(Builder, Deserialize, Serialize, Clone)] +#[derive(Debug, Builder, Deserialize, Serialize, Clone)] #[serde(rename_all = "PascalCase")] pub struct Nonprofit { #[builder(setter(into))] diff --git a/src/services/airtable/mod.rs b/src/services/airtable/mod.rs index 09de79c..30c8b33 100644 --- a/src/services/airtable/mod.rs +++ b/src/services/airtable/mod.rs @@ -167,7 +167,7 @@ impl AirtableClient for Airtable { let mut volunteers = Vec::::with_capacity(300); loop { - let res = self.list_records::(base_id, "Volunteers", query.clone()).await?; + let res = self.list_records::(base_id, "Volunteers", Some(&query)).await?; volunteers .append(&mut res.records.into_iter().map(|data| data.fields).collect::>()); @@ -190,7 +190,7 @@ impl AirtableClient for Airtable { let mut mentors = Vec::::with_capacity(100); loop { - let res = self.list_records::(base_id, "Volunteers", query.clone()).await?; + let res = self.list_records::(base_id, "Volunteers", Some(&query)).await?; mentors .append(&mut res.records.into_iter().map(|data| data.fields).collect::>()); @@ -226,7 +226,7 @@ impl AirtableClient for Airtable { let mut nonprofits = Vec::::with_capacity(100); loop { - let res = self.list_records::(base_id, "Nonprofits", query.clone()).await?; + let res = self.list_records::(base_id, "Nonprofits", Some(&query)).await?; nonprofits .append(&mut res.records.into_iter().map(|data| data.fields).collect::>()); @@ -249,7 +249,7 @@ impl AirtableClient for Airtable { let mut linkages = Vec::::with_capacity(100); loop { let res = self - .list_records::(base_id, "Volunteers", query.clone()) + .list_records::(base_id, "Volunteers", Some(&query)) .await?; linkages diff --git a/src/services/airtable/tests/mod.rs b/src/services/airtable/tests/mod.rs index 3f4eb95..a387f35 100644 --- a/src/services/airtable/tests/mod.rs +++ b/src/services/airtable/tests/mod.rs @@ -10,9 +10,9 @@ use crate::services::airtable::AirtableClient; #[rstest] #[tokio::test] pub async fn test_list_mentors(airtable: Airtable) -> Result<()> { - let mentors = airtable.list_mentors("appS5z0uqz4l0IJvP").await?; + let records = airtable.list_mentors("appcOdqCMHAlxDqDZ").await?; - dbg!(&mentors); + dbg!(&records); Ok(()) } diff --git a/src/services/mail/mod.rs b/src/services/mail/mod.rs index 6475d08..c43a7e8 100644 --- a/src/services/mail/mod.rs +++ b/src/services/mail/mod.rs @@ -66,11 +66,11 @@ impl TryFrom for Mail { .build()?; let from = AddressBuilder::default() - .email("pantheon@developforgood.org") - .name("Pantheon".to_owned()) + .email("onboarding@developforgood.org") + .name("Develop for Good".to_owned()) .build()?; - let subject = "Welcome to Pantheon!".to_owned(); + let subject = "Develop for Good: Onboarding instructions".to_owned(); let mut context = Context::new(); context.insert("name", &value.first_name); diff --git a/src/services/storage/types.rs b/src/services/storage/types.rs index c3e8aab..92444b0 100644 --- a/src/services/storage/types.rs +++ b/src/services/storage/types.rs @@ -109,6 +109,9 @@ pub struct JobDetails { #[sqlx(type_name = "age_range")] #[serde(rename_all = "camelCase")] pub enum AgeRange { + #[serde(rename = "17 and under")] + #[sqlx(rename = "17_and_under")] + RUnder17, #[serde(rename = "18 - 24")] #[sqlx(rename = "18-24")] R18_24, @@ -133,6 +136,9 @@ pub enum AgeRange { #[serde(rename = "65+")] #[sqlx(rename = "65+")] ROver65, + #[serde(rename = "Prefer not to say")] + #[sqlx(rename = "prefer_not_to_say")] + PreferNotToSay, } /// Possible ethnicities for volunteers diff --git a/templates/email/header.html b/templates/email/header.html index f17780e..edc1192 100644 --- a/templates/email/header.html +++ b/templates/email/header.html @@ -1,4 +1,7 @@
- +
- diff --git a/templates/email/onboard.html b/templates/email/onboard.html index d5d8898..1635ab1 100644 --- a/templates/email/onboard.html +++ b/templates/email/onboard.html @@ -1,26 +1,35 @@ {% extends "email/base.html" %} {% block content %} -

Welcome {{ name }},

+

Greetings {{ name }},

- We're glad to have you at Develop for Good! To get started, you will need to - activate your email. Your new Develop for Good email is: + Welcome to Develop for Good! To get started with this batch, you will need to + activate your Develop for Good email at your earliest convenience.

- - {{ email }}, and your temporary password is: - {{ temporaryPassword }}. - -

- Once you log in, you will be prompted to change your password. Please do so - as soon as you can so that you can access Slack, Notion, Figma, and other - internal services for this project batch. + Your new Develop for Good email is: {{ email }}
Your temporary password + is: {{ temporaryPassword }}

- +
+Please sign in with your credentials above here: Google Workspace Login. Once you log in, you will be prompted to change your password. + Once you log in, you will be prompted to change your password. +

- If you have any questions, please don't hesitate to reach out to your - product lead or the Develop for Good management team. + We will be sending you an invitation to join our Slack workspace through your + Develop for Good email shortly. Once you log into our organization Slack, you + will be able to introduce yourself to your teammates and nonprofit client on + your project-specific channel. All project and org-wide communications will be + hosted on Slack, including reminders and recaps about our upcoming sessions + this week for orientation and onboarding. +

+

+ We request that you activate your email and accept our Slack invite as soon as + it lands in your inbox to avoid delays with onboarding. If you have any + questions, please don't hesitate to email onboarding@developforgood.org for help. +

+

+ Welcome aboard!

{% endblock content %}