diff --git a/docker-compose.yml b/docker-compose.yml index d36892b..2e1df5b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: POSTGRES_USER: school_app_api_user PGDATA: /db volumes: - - ./pgdata:/db:rw + - /home/mitchell/prod/school_app_api/pgdata:/db:rw school-app-api: image: registry.mdev.local/school_app_api diff --git a/migrations/20230414021153_event_attending.sql b/migrations/20230414021153_event_attending.sql new file mode 100644 index 0000000..83998b7 --- /dev/null +++ b/migrations/20230414021153_event_attending.sql @@ -0,0 +1,5 @@ +-- fill existing users with grade 9 +ALTER TABLE users ADD COLUMN grade INTEGER NOT NULL DEFAULT 9; +ALTER TABLE users ALTER COLUMN grade DROP DEFAULT; + +ALTER TABLE event_attendees ADD COLUMN confirmed BOOLEAN NOT NULL DEFAULT false; diff --git a/src/events.rs b/src/events.rs index 8fa281d..109549f 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,107 +1,14 @@ -use axum::{ - extract::State, - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use axum_auth::AuthBearer; -use jsonwebtoken::{Algorithm, Validation}; -use serde::{Deserialize, Serialize}; use serde_json::json; -use sqlx::{ - query, query_as, - types::{chrono::NaiveDateTime, BigDecimal}, +use sqlx::{query, query_as}; + +use crate::{ + jwt::handle_token, + models::{Event, EventType, NewEventRequestEntry, Role}, + AppState, }; -use crate::{jwt::Claims, user::Role, AppState}; - -#[derive( - sqlx::Type, Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, -)] -#[sqlx(type_name = "event_type", rename_all = "snake_case")] -enum EventType { - Sports, - Meetings, - Drama, - Music, - Other, -} - -#[derive(Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -pub struct EventsPreviewQuery { - count: u32, -} - -#[derive(Clone, Deserialize, Debug)] -pub struct NewEventRequestEntry { - title: String, - description: String, - #[serde(with = "serialize_datetime")] - time_start: NaiveDateTime, - #[serde(with = "serialize_datetime")] - time_end: NaiveDateTime, - event_type: EventType, - points: i32, - place: Option, - #[serde(with = "serialize_big_decimal")] - price: BigDecimal, -} - -#[derive(Clone, Serialize, Debug)] -pub struct Event { - id: i32, - title: String, - description: String, - #[serde(with = "serialize_datetime")] - time_start: NaiveDateTime, - #[serde(with = "serialize_datetime")] - time_end: NaiveDateTime, - event_type: EventType, - points: i32, - place: Option, - #[serde(with = "serialize_big_decimal")] - price: BigDecimal, - created_by: Option, -} - -mod serialize_datetime { - use std::str::FromStr; - - use serde::{self, Deserialize, Deserializer, Serializer}; - use sqlx::types::chrono::NaiveDateTime; - - pub fn serialize(dt: &NaiveDateTime, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&dt.to_string()) - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result { - let str = ::deserialize(deserializer)?; - NaiveDateTime::from_str(&str).map_err(|err| serde::de::Error::custom(err)) - } -} - -mod serialize_big_decimal { - use serde::{self, Deserialize, Deserializer, Serializer}; - use sqlx::types::BigDecimal; - - pub fn serialize(bd: &BigDecimal, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&bd.to_string()) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { - let float = f32::deserialize(deserializer)?; - BigDecimal::try_from(float).map_err(|err| serde::de::Error::custom(err)) - } -} - pub async fn get_events_preview(State(app_state): State) -> impl IntoResponse { let result = query_as!( Event, @@ -144,7 +51,7 @@ LIMIT pub async fn create_event( AuthBearer(token): AuthBearer, State(app_state): State, - Json(event_post_query): Json, + Json(event_req): Json, ) -> impl IntoResponse { let token_data = match handle_token(token, &app_state, Role::Teacher) { Ok(value) => value, @@ -157,14 +64,14 @@ pub async fn create_event( VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9 ) RETURNING id "#, - event_post_query.title, - event_post_query.description, - event_post_query.time_start, - event_post_query.time_end, - event_post_query.event_type as EventType, - event_post_query.points, - event_post_query.place, - event_post_query.price, + event_req.title, + event_req.description, + event_req.time_start, + event_req.time_end, + event_req.event_type as EventType, + event_req.points, + event_req.place, + event_req.price, token_data.id, ).fetch_one(&app_state.db_pool).await; @@ -179,35 +86,3 @@ pub async fn create_event( .into_response(), } } - -fn handle_token( - token: String, - app_state: &AppState, - minimum_role: Role, -) -> Result { - let token_result = jsonwebtoken::decode::( - &token, - &app_state.jwt_decode, - &Validation::new(Algorithm::HS256), - ); - let token_data = match token_result { - Ok(token_data) => token_data.claims, - Err(err) => { - return Err(( - StatusCode::BAD_REQUEST, - Json(json!({ "error": format!("Failed to parse JWT: {}", err) })), - ) - .into_response()) - } - }; - if token_data.role < minimum_role { - return Err(( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": format!("You must be at least a {:?} to do this", minimum_role) - })), - ) - .into_response()); - } - Ok(token_data) -} diff --git a/src/jwt.rs b/src/jwt.rs index 650d931..88fb3cf 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -1,22 +1,18 @@ +use crate::{ + models::{Claims, Role}, + AppState, +}; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Validation}; +use serde_json::json; use std::time::Duration; -use jsonwebtoken::{DecodingKey, EncodingKey}; -use serde::{Deserialize, Serialize}; -use sqlx::types::time::OffsetDateTime; - -use crate::user::Role; - pub const JWT_LIFETIME: Duration = Duration::from_secs(60 * 60 * 24 * 7); -#[derive(Clone, Serialize, Deserialize, Debug, Hash)] -pub struct Claims { - #[serde(with = "jwt_numeric_date")] - pub exp: OffsetDateTime, - pub id: i32, - pub username: String, - pub role: Role, -} - pub fn get_jwt_keys() -> (EncodingKey, DecodingKey) { let secret = std::env::var("SAAPI_JWT_SECRET").unwrap_or("Sdb\\y5PP`,hmG+98".into()); ( @@ -25,26 +21,34 @@ pub fn get_jwt_keys() -> (EncodingKey, DecodingKey) { ) } -mod jwt_numeric_date { - //! Custom serialization of OffsetDateTime to conform with the JWT spec (RFC 7519 section 2, "Numeric Date") - use serde::{self, Deserialize, Deserializer, Serializer}; - use sqlx::types::time::OffsetDateTime; - - /// Serializes an OffsetDateTime to a Unix timestamp (milliseconds since 1970/1/1T00:00:00T) - pub fn serialize(date: &OffsetDateTime, serializer: S) -> Result - where - S: Serializer, - { - let timestamp = date.unix_timestamp(); - serializer.serialize_i64(timestamp) - } - - /// Attempts to deserialize an i64 and use as a Unix timestamp - pub fn deserialize<'de, D>(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - OffsetDateTime::from_unix_timestamp(i64::deserialize(deserializer)?) - .map_err(|_| serde::de::Error::custom("invalid Unix timestamp value")) +pub fn handle_token( + token: String, + app_state: &AppState, + minimum_role: Role, +) -> Result { + let token_result = jsonwebtoken::decode::( + &token, + &app_state.jwt_decode, + &Validation::new(Algorithm::HS256), + ); + let token_data = match token_result { + Ok(token_data) => token_data.claims, + Err(err) => { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!("Failed to parse JWT: {}", err) })), + ) + .into_response()) + } + }; + if token_data.role < minimum_role { + return Err(( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": format!("You must be at least a {:?} to do this", minimum_role) + })), + ) + .into_response()); } + Ok(token_data) } diff --git a/src/main.rs b/src/main.rs index bac875f..ee60787 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ mod events; mod jwt; +mod models; +mod report; mod user; use axum::{ diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..1425ff9 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,162 @@ +use serde::{Deserialize, Serialize}; +use sqlx::types::{chrono::NaiveDateTime, time::OffsetDateTime, BigDecimal}; + +/// The possible event types. +#[derive( + sqlx::Type, Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, +)] +#[sqlx(type_name = "event_type", rename_all = "snake_case")] +pub enum EventType { + Sports, + Meetings, + Drama, + Music, + Other, +} + +/// The model for the body of the create event request. +#[derive(Clone, Deserialize, Debug)] +pub struct NewEventRequestEntry { + pub title: String, + pub description: String, + #[serde(with = "serde_datetime")] + pub time_start: NaiveDateTime, + #[serde(with = "serde_datetime")] + pub time_end: NaiveDateTime, + pub event_type: EventType, + pub points: i32, + pub place: Option, + #[serde(with = "serde_big_decimal")] + pub price: BigDecimal, +} + +/// The model for an Event in the db. +#[derive(Clone, Serialize, Debug)] +pub struct Event { + pub id: i32, + pub title: String, + pub description: String, + #[serde(with = "serde_datetime")] + pub time_start: NaiveDateTime, + #[serde(with = "serde_datetime")] + pub time_end: NaiveDateTime, + pub event_type: EventType, + pub points: i32, + pub place: Option, + #[serde(with = "serde_big_decimal")] + pub price: BigDecimal, + pub created_by: Option, +} + +/// The model for the body of the signup request. +#[derive(Clone, Debug, Deserialize)] +pub struct Signup { + pub username: String, + pub role: Role, + pub grade: i32, + pub password: String, +} + +/// The possible roles for a user. +#[derive( + sqlx::Type, Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, +)] +// #[sqlx(type_name = "role", rename_all = "snake_case")] +pub enum Role { + Student, + Teacher, + Admin, +} + +/// The model for the signin request. +#[derive(Clone, Debug, Deserialize)] +pub struct Signin { + pub username: String, + pub password: String, +} + +/// The model for the query parameters of the report request. +#[derive(Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct ReportQuery { + pub grade: Option, +} + +/// The claims for the JWT. +#[derive(Clone, Serialize, Deserialize, Debug, Hash)] +pub struct Claims { + #[serde(with = "serde_numeric_date")] + pub exp: OffsetDateTime, + pub id: i32, + pub username: String, + pub role: Role, +} + +#[derive( + sqlx::Type, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, +)] +pub struct UserReportEntry { + pub username: String, + pub points: i32, +} + +/// Module for (de)serializing [OffsetDateTime] to conform with the JWT spec (RFC 7519 section 2, "Numeric Date") +mod serde_numeric_date { + use serde::{self, Deserialize, Deserializer, Serializer}; + use sqlx::types::time::OffsetDateTime; + + pub fn serialize(date: &OffsetDateTime, serializer: S) -> Result + where + S: Serializer, + { + let timestamp = date.unix_timestamp(); + serializer.serialize_i64(timestamp) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + OffsetDateTime::from_unix_timestamp(i64::deserialize(deserializer)?) + .map_err(|_| serde::de::Error::custom("invalid Unix timestamp value")) + } +} + +/// Module for (de)serializing [NaiveDateTime] +mod serde_datetime { + use std::str::FromStr; + + use serde::{self, Deserialize, Deserializer, Serializer}; + use sqlx::types::chrono::NaiveDateTime; + + pub fn serialize(dt: &NaiveDateTime, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&dt.to_string()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result { + let str = ::deserialize(deserializer)?; + NaiveDateTime::from_str(&str).map_err(|err| serde::de::Error::custom(err)) + } +} + +/// Module for (de)serializing [BigDecimal] +mod serde_big_decimal { + use serde::{self, Deserialize, Deserializer, Serializer}; + use sqlx::types::BigDecimal; + + pub fn serialize(bd: &BigDecimal, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&bd.to_string()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let float = f32::deserialize(deserializer)?; + BigDecimal::try_from(float).map_err(|err| serde::de::Error::custom(err)) + } +} diff --git a/src/report.rs b/src/report.rs new file mode 100644 index 0000000..e6f2c30 --- /dev/null +++ b/src/report.rs @@ -0,0 +1,59 @@ +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use axum_auth::AuthBearer; +use serde_json::json; +use sqlx::{query, query_as}; + +use crate::{ + jwt::handle_token, + models::{ReportQuery, Role, UserReportEntry}, + AppState, +}; + +pub async fn report( + AuthBearer(token): AuthBearer, + State(app_state): State, + Query(report_query): Query, +) -> impl IntoResponse { + let token_data = match handle_token(token, &app_state, Role::Teacher) { + Ok(value) => value, + Err(value) => return value, + }; + + let result = query_as!( + UserReportEntry, + r#" + SELECT + u.username, + COALESCE(SUM(e.points), 0) AS "points!: i32" + FROM + users u + LEFT JOIN event_attendees ea + ON u.id = ea.user_id AND ea.confirmed = true + LEFT JOIN events e + ON ea.event_id = e.id + WHERE + u.grade = $1 OR $2 + GROUP BY u.id; + "#, + report_query.grade.unwrap_or(0), + report_query.grade.is_none(), + ) + .fetch_all(&app_state.db_pool) + .await; + + match result { + Ok(record) => (StatusCode::OK, Json(json!({ "data": record }))).into_response(), + Err(err) => ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": format!("Unknown error creating event: {}", err) + })), + ) + .into_response(), + } +} diff --git a/src/user.rs b/src/user.rs index 25da2e8..4a44292 100644 --- a/src/user.rs +++ b/src/user.rs @@ -1,43 +1,14 @@ use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use jsonwebtoken::Header; -use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::types::time::OffsetDateTime; use crate::{ - jwt::{Claims, JWT_LIFETIME}, + jwt::JWT_LIFETIME, + models::{Claims, Role, Signin, Signup}, AppState, }; -#[derive(Clone, Debug, Deserialize)] -pub struct Signup { - username: String, - role: Role, - password: String, -} - -#[derive( - sqlx::Type, Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, -)] -#[sqlx(type_name = "role", rename_all = "snake_case")] -pub enum Role { - Student, - Teacher, - Admin, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct Signin { - username: String, - password: String, -} - -#[derive(Serialize, sqlx::FromRow)] -struct User { - id: u64, - username: String, -} - pub async fn signup( State(app_state): State, Json(signup): Json, @@ -47,12 +18,13 @@ pub async fn signup( let result = sqlx::query!( r#" - INSERT INTO users (username, role, password) - VALUES ( $1, $2, $3 ) + INSERT INTO users (username, role, grade, password) + VALUES ( $1, $2, $3, $4 ) RETURNING id; "#, signup.username, signup.role as Role, + signup.grade, pass_hash.as_bytes(), ) .fetch_one(&app_state.db_pool)