Compare commits

...

10 Commits

Author SHA1 Message Date
Mitchell Marino
e0ce62ddb7 comment event endpoint 2023-04-16 23:27:28 -05:00
Mitchell Marino
234ae29e96 new get/post prize endpoints 2023-04-16 22:31:15 -05:00
Mitchell Marino
f8fc5d3dce add get_attending endpoint 2023-04-16 22:01:57 -05:00
Mitchell Marino
c5bbad6460 fix report err 2023-04-16 20:12:55 -05:00
Mitchell Marino
8d08147329 events api 2023-04-16 19:59:24 -05:00
Mitchell Marino
db6a5414d9 leaderboard work 2023-04-16 17:47:16 -05:00
Mitchell Marino
334ef065af fix cors again? 2023-04-16 17:38:13 -05:00
Mitchell Marino
2f334d233b fix cors 2023-04-16 17:19:42 -05:00
Mitchell Marino
85ee4589d4 fix cors 2023-04-16 17:00:42 -05:00
Mitchell Marino
ed5f50183a add cors support 2023-04-16 16:33:42 -05:00
11 changed files with 328 additions and 45 deletions

View File

@ -8,6 +8,8 @@ edition = "2021"
[dependencies]
tokio = { version = "1.27", features = ["full"] }
axum = "0.6"
tower = "0.4"
tower-http = { version = "0.4", features = ["cors"]}
axum-auth = { version = "0.4", features = ["auth-bearer"]}
sqlx = { version = "0.6", features = ["postgres", "runtime-tokio-rustls", "all-types"] }
serde = { version = "1.0", features = ["derive"] }

4
README.md Normal file
View File

@ -0,0 +1,4 @@
# EngageEarn API
The api for the EngageEarn app.

View File

@ -148,6 +148,7 @@ jobs:
- name: build
plan:
- get: docker-image
- get: repo
trigger: true
passed: [test]
@ -190,6 +191,7 @@ jobs:
repository: concourse/oci-build-task
inputs:
- name: docker-image
- name: repo
path: .

View File

@ -1,11 +1,16 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use axum_auth::AuthBearer;
use serde_json::json;
use sqlx::query;
use sqlx::{query, query_as};
use crate::{
jwt::handle_token,
models::{ConfirmAttending, MarkAttending, Role},
models::{AttendingEntry, ConfirmAttending, GetAttendingQuery, MarkAttending, Role},
AppState,
};
@ -57,6 +62,44 @@ pub async fn confirm_attending(
.into_response()
}
pub async fn get_attending(
AuthBearer(token): AuthBearer,
State(app_state): State<AppState>,
Query(attending_query): Query<GetAttendingQuery>,
) -> impl IntoResponse {
match handle_token(token, &app_state, Role::Teacher) {
Ok(token_data) => token_data,
Err(err) => return err,
};
let result = query_as!(
AttendingEntry,
r#"
SELECT
user_id,
u.username
FROM event_attendees ea
INNER JOIN users u
ON u.id = ea.user_id
WHERE event_id = $1
"#,
attending_query.event_id,
)
.fetch_all(&app_state.db_pool)
.await;
match result {
Ok(attending) => (StatusCode::OK, Json(json!(attending))),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Unknown error confirming attendence: {:?}", err)
})),
),
}
.into_response()
}
pub async fn mark_attending(
AuthBearer(token): AuthBearer,
State(app_state): State<AppState>,

View File

@ -18,9 +18,8 @@ pub async fn get_events_preview(
AuthBearer(token): AuthBearer,
State(app_state): State<AppState>,
) -> impl IntoResponse {
match handle_token(token, &app_state, Role::Student) {
Ok(_) => {}
Err(err) => return err,
if let Err(err) = handle_token(token, &app_state, Role::Student) {
return err;
};
let result = query_as!(
@ -66,9 +65,8 @@ pub async fn get_all_events(
AuthBearer(token): AuthBearer,
State(app_state): State<AppState>,
) -> impl IntoResponse {
match handle_token(token, &app_state, Role::Student) {
Ok(_) => {}
Err(err) => return err,
if let Err(err) = handle_token(token, &app_state, Role::Student) {
return err;
};
let result = query_as!(
@ -110,6 +108,55 @@ pub async fn get_all_events(
.into_response()
}
pub async fn my_events(
AuthBearer(token): AuthBearer,
State(app_state): State<AppState>,
) -> impl IntoResponse {
let token_data = match handle_token(token, &app_state, Role::Teacher) {
Err(err) => return err,
Ok(token_data) => token_data,
};
let result = query_as!(
Event,
r#"
SELECT
id,
title,
description,
time_start,
time_end,
event_type AS "event_type!: EventType",
points,
place,
price,
created_by
FROM
events
WHERE
created_by = $1
ORDER BY
time_start
LIMIT
20;
"#,
token_data.id,
)
.fetch_all(&app_state.db_pool)
.await;
match result {
Ok(events) => (StatusCode::OK, Json(json!(events))),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Unknown error getting events: {:?}", err)
})),
),
}
.into_response()
}
pub async fn get_recent_events(
AuthBearer(token): AuthBearer,
State(app_state): State<AppState>,
@ -215,6 +262,62 @@ pub async fn get_event(
.into_response()
}
/// Deletes an event from the database.
///
/// If you are a teacher, this only works on your own events.
/// If you are an admin, this works on any event.
pub async fn delete_event(
AuthBearer(token): AuthBearer,
State(app_state): State<AppState>,
Query(get_event_query): Query<GetEventQuery>,
) -> impl IntoResponse {
// validate the JWT
let token_data = match handle_token(token, &app_state, Role::Teacher) {
Err(err) => return err,
Ok(token_data) => token_data,
};
// query the database to delete the record
let result = query!(
r#"
DELETE FROM events
WHERE
id = $1 AND
(created_by = $2 OR $3)
"#,
get_event_query.id,
token_data.id,
token_data.role == Role::Admin,
)
.execute(&app_state.db_pool)
.await;
// handle any possible error from the database
match result {
Ok(result) => {
if result.rows_affected() == 1 {
(StatusCode::OK, Json(json!({})))
} else {
// no reccord could be deleted
(
StatusCode::NOT_FOUND,
Json(json!({
"error": format!("Event {} not found.", get_event_query.id)
})),
)
}
}
// unknown database error
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Unknown error getting event: {:?}", err)
})),
),
}
.into_response()
}
pub async fn create_event(
AuthBearer(token): AuthBearer,
State(app_state): State<AppState>,
@ -245,7 +348,7 @@ pub async fn create_event(
match result {
Ok(record) => (StatusCode::OK, Json(json!({ "data": record.id }))).into_response(),
Err(err) => (
StatusCode::BAD_REQUEST,
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Unknown error creating event: {:?}", err)
})),

View File

@ -51,8 +51,9 @@ pub async fn list_points(
AuthBearer(token): AuthBearer,
State(app_state): State<AppState>,
) -> impl IntoResponse {
if let Err(err) = handle_token(token, &app_state, Role::Student) {
return err;
let token_data = match handle_token(token, &app_state, Role::Student) {
Err(err) => return err,
Ok(token_data) => token_data,
};
let result = query_as!(
@ -67,10 +68,14 @@ pub async fn list_points(
ON u.id = ea.user_id AND ea.confirmed = true
LEFT JOIN events e
ON ea.event_id = e.id
WHERE
u.grade = $1 AND
u.role = 'student'
GROUP BY u.id
ORDER BY points DESC
;
"#,
token_data.grade,
)
.fetch_all(&app_state.db_pool)
.await;

View File

@ -7,6 +7,8 @@ mod report;
mod user;
mod winner;
use std::net::SocketAddr;
use axum::{
routing::{delete, get, post, put},
Router,
@ -14,7 +16,7 @@ use axum::{
use jsonwebtoken::{DecodingKey, EncodingKey};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::net::SocketAddr;
use tower_http::cors::{self, CorsLayer};
use user::{signin, signup};
#[derive(Clone)]
@ -45,12 +47,19 @@ async fn main() {
.route("/", get(root))
.route("/user/signup", post(signup))
.route("/user/signin", post(signin))
.route("/event", get(events::get_event).post(events::create_event))
.route(
"/event",
get(events::get_event)
.post(events::create_event)
.delete(events::delete_event),
)
.route("/event/preview", get(events::get_events_preview))
.route("/event/future", get(events::get_all_events))
.route("/event/recent", get(events::get_recent_events))
.route("/event/my", get(events::my_events))
.route("/report", get(report::get_report))
.route("/attending/confirm", put(attending::confirm_attending))
.route("/attending", get(attending::get_attending))
.route("/attending/mark", post(attending::mark_attending))
.route("/attending/unmark", delete(attending::unmark_attending))
.route("/leaderboard/my_points", get(leaderboard::get_points))
@ -59,12 +68,20 @@ async fn main() {
.route("/winners/recent", get(winner::get_recent_winners))
.route("/winners/unclaimed", get(winner::get_unclaimed_winners))
.route("/winners/mark", put(winner::mark_claimed))
.route("/prizes", get(winner::get_prizes).post(winner::new_prize))
.with_state(AppState {
db_pool,
jwt_encode,
jwt_decode,
});
// Create CORS midleware to allow the web build of the UI to work.
let cors = CorsLayer::new()
.allow_methods(cors::Any)
.allow_headers(cors::Any)
.allow_origin(cors::Any);
let app = app.layer(cors);
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));

View File

@ -19,6 +19,35 @@ pub struct WinnerEntry {
#[derive(Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct PrizeQuery {
pub prize_id: Option<i32>,
pub grade: i32,
}
#[derive(Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct PrizeEntry {
pub id: i32,
pub name: String,
pub points_min: i32,
pub description: String,
pub directions_to_claim: String,
}
#[derive(Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct NewPrize {
pub name: String,
pub points_min: i32,
pub description: String,
pub directions_to_claim: String,
}
#[derive(Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct AttendingEntry {
pub user_id: i32,
pub username: String,
}
#[derive(Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct GetAttendingQuery {
pub event_id: i32,
}
#[derive(Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@ -154,6 +183,7 @@ pub struct Claims {
pub exp: OffsetDateTime,
pub id: i32,
pub username: String,
pub grade: i32,
pub role: Role,
}

View File

@ -46,15 +46,16 @@ pub async fn get_report(
u.username,
u.grade,
COALESCE(SUM(e.points), 0) AS points,
array_agg(e.title) AS "event_titles!",
array_agg(e.description) AS "event_discriptions!",
array_agg(e.points) AS "event_points!",
array_agg(e.event_type) AS "event_types!: Vec<String>"
COALESCE(array_agg(e.title), ARRAY[]::varchar(255)[]) AS "event_titles!",
COALESCE(array_agg(e.description), ARRAY[]::text[]) AS "event_discriptions!",
COALESCE(array_agg(e.points), ARRAY[]::INTEGER[]) AS "event_points!",
COALESCE(array_agg(e.event_type), ARRAY[]::event_type[]) AS "event_types!: Vec<String>"
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.role = 'student'
GROUP BY u.id
ORDER BY u.grade, u.username, points
"#,
@ -66,9 +67,9 @@ pub async fn get_report(
Ok(records) => records,
Err(err) => {
return (
StatusCode::BAD_REQUEST,
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Unknown error creating event: {:?}", err)
"error": format!("Unknown error getting user data: {:?}", err)
})),
)
.into_response()
@ -84,9 +85,9 @@ pub async fn get_report(
Ok(report) => report,
Err(err) => {
return (
StatusCode::BAD_REQUEST,
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Unknown error creating event: {:?}", err)
"error": format!("Unknown error rendering report: {:?}", err)
})),
)
.into_response()

View File

@ -64,12 +64,13 @@ pub async fn signin(
let result = sqlx::query!(
r#"
SELECT
id,
username,
role AS "role!: Role"
FROM users
WHERE username = $1 AND password = $2
SELECT
id,
username,
role AS "role!: Role",
grade
FROM users
WHERE username = $1 AND password = $2
"#,
signin.username,
pass_hash.as_bytes(),
@ -82,6 +83,7 @@ pub async fn signin(
let claims = Claims {
exp: OffsetDateTime::now_utc() + JWT_LIFETIME,
id: user.id,
grade: user.grade,
username: user.username,
role: user.role,
};
@ -101,21 +103,17 @@ pub async fn signin(
(StatusCode::OK, Json(json!({ "data": token })))
}
Ok(None) => {
(
StatusCode::UNAUTHORIZED,
Json(json!({
"error": format!("Incorrect username or password")
})),
)
}
Err(err) => {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Unknown error signing in: {:?}", err)
})),
)
}
Ok(None) => (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": format!("Incorrect username or password")
})),
),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Unknown error signing in: {:?}", err)
})),
),
}
}

View File

@ -9,7 +9,7 @@ use axum_auth::AuthBearer;
use serde_json::json;
use sqlx::{query, query_as};
use crate::models::{WinnerEntry, WinnerQuery};
use crate::models::{NewPrize, PrizeEntry, WinnerEntry, WinnerQuery};
use crate::{
jwt::handle_token,
models::{PrizeQuery, Role},
@ -31,6 +31,7 @@ pub async fn select_winners(
SELECT
u.id,
u.username,
u.grade,
COALESCE(SUM(e.points), 0) AS points,
RANDOM() * COALESCE(SUM(e.points), 0) AS random_value
FROM
@ -50,6 +51,7 @@ pub async fn select_winners(
id, points
FROM weighted_users
WHERE points > p.points_min
AND grade = $3
LIMIT 1
) u
WHERE p.id = $1 OR $2
@ -61,6 +63,7 @@ pub async fn select_winners(
"#,
prize_query.prize_id.unwrap_or(0),
prize_query.prize_id.is_none(),
prize_query.grade,
)
.execute(&app_state.db_pool)
.await;
@ -220,3 +223,78 @@ pub async fn mark_claimed(
}
.into_response()
}
pub async fn get_prizes(
AuthBearer(token): AuthBearer,
State(app_state): State<AppState>,
) -> Response {
if let Err(err) = handle_token(token, &app_state, Role::Student) {
return err;
};
let result = query_as!(
PrizeEntry,
r#"
SELECT
*
FROM prizes
"#,
)
.fetch_all(&app_state.db_pool)
.await;
match result {
Ok(prizes) => (StatusCode::OK, Json(json!(prizes))),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Unknown error getting winners: {:?}", err)
})),
),
}
.into_response()
}
pub async fn new_prize(
AuthBearer(token): AuthBearer,
State(app_state): State<AppState>,
Json(new_prize): Json<NewPrize>,
) -> Response {
if let Err(err) = handle_token(token, &app_state, Role::Student) {
return err;
};
let result = query_as!(
PrizeEntry,
r#"
INSERT INTO prizes (name, points_min, description, directions_to_claim)
VALUES ($1, $2, $3, $4)
"#,
new_prize.name,
new_prize.points_min,
new_prize.description,
new_prize.directions_to_claim,
)
.execute(&app_state.db_pool)
.await;
match result {
Ok(result) => {
if result.rows_affected() == 1 {
(StatusCode::OK, Json(json!({})))
} else {
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "Failed to insert prize."})),
)
}
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!("Unknown error getting winners: {:?}", err)
})),
),
}
.into_response()
}