From 5fe7adda70ffcce6dfb85479397726e3f2a62fdc Mon Sep 17 00:00:00 2001 From: alexlai18 <104405034+alexlai18@users.noreply.github.com> Date: Fri, 28 Jun 2024 17:44:07 +1000 Subject: [PATCH] Organisations CRUD (#485) * feat: Initial draft TODO: TESTING + more clarification * rearranged import * clear errors * post testing * feat(db): add indexes for foreign key reference columns * fix(db): index naming and missing semicolons * dep(backend): update axum * fix(backend): remove testing jwt handlers * feat(backend): Add custom jwt validator and header * feat(backend): basic error handling enum * fix(backend): ran cargo fmt * fix(backend): remove unused imports * CRUD operations - awaiting Campaign - haven't enforced db safety * implement feedback * Update rust.yml to include 224 branch * logic and style fixes * Change to using `thiserror` * add organisation_role type to db * update migration timestamps to be `NOT NULL` * change sqlx `time` to `chrono` * integrate organisations crud with error handling * return member role with org members * update sqlx type name for `UserRole` * simplify handlers to use new error type * removed unused imports * added `OrganisationAdmin` extractor * use `Transaction` when doing multiple queries * cargo fmt * add org route to app * move `Organisation` service functions into `Organisation` struct * moved org handlers into `OrganisationHandler` struct * ran cargo fmt * add S3 url generation to logo update * fixed error renaming * add routes to `main.rs` * cargo fmt --------- Co-authored-by: Alexander Co-authored-by: Kavika --- backend/api.json | 4 + backend/api.yaml | 3 + .../20240406023149_create_users.sql | 4 +- .../20240406024211_create_organisations.sql | 13 +- .../20240406025537_create_campaigns.sql | 8 +- .../20240406031400_create_questions.sql | 4 +- .../20240406031915_create_applications.sql | 8 +- backend/server/Cargo.toml | 4 +- backend/server/src/handler/mod.rs | 1 + backend/server/src/handler/organisation.rs | 160 ++++++++ backend/server/src/main.rs | 36 +- backend/server/src/models/app.rs | 2 + backend/server/src/models/auth.rs | 48 ++- backend/server/src/models/campaign.rs | 12 + backend/server/src/models/error.rs | 21 +- backend/server/src/models/mod.rs | 4 + backend/server/src/models/organisation.rs | 345 ++++++++++++++++++ backend/server/src/models/storage.rs | 54 +++ backend/server/src/models/transaction.rs | 27 ++ backend/server/src/models/user.rs | 2 +- backend/server/src/service/auth.rs | 2 +- backend/server/src/service/jwt.rs | 2 +- backend/server/src/service/mod.rs | 1 + backend/server/src/service/organisation.rs | 28 ++ 24 files changed, 762 insertions(+), 31 deletions(-) create mode 100644 backend/server/src/handler/organisation.rs create mode 100644 backend/server/src/models/campaign.rs create mode 100644 backend/server/src/models/organisation.rs create mode 100644 backend/server/src/models/storage.rs create mode 100644 backend/server/src/models/transaction.rs create mode 100644 backend/server/src/service/organisation.rs diff --git a/backend/api.json b/backend/api.json index f070ae82..8be962fe 100644 --- a/backend/api.json +++ b/backend/api.json @@ -746,6 +746,10 @@ "name": { "type": "string", "example": "Clancy Lion" + }, + "role": { + "type": "string", + "example": "Admin" } } } diff --git a/backend/api.yaml b/backend/api.yaml index 64be4694..0456733a 100644 --- a/backend/api.yaml +++ b/backend/api.yaml @@ -482,6 +482,9 @@ paths: name: type: string example: Clancy Lion + role: + type: string + example: Admin '403': description: User is not an organisation admin or member. content: diff --git a/backend/migrations/20240406023149_create_users.sql b/backend/migrations/20240406023149_create_users.sql index 0bb4cd8b..f862c0da 100644 --- a/backend/migrations/20240406023149_create_users.sql +++ b/backend/migrations/20240406023149_create_users.sql @@ -8,8 +8,8 @@ CREATE TABLE users ( degree_name TEXT, degree_starting_year INTEGER, role user_role NOT NULL, - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL ); CREATE UNIQUE INDEX IDX_users_email_lower on users ((lower(email))); diff --git a/backend/migrations/20240406024211_create_organisations.sql b/backend/migrations/20240406024211_create_organisations.sql index 88dda366..2e9c8bf7 100644 --- a/backend/migrations/20240406024211_create_organisations.sql +++ b/backend/migrations/20240406024211_create_organisations.sql @@ -2,19 +2,22 @@ CREATE TABLE organisations ( id BIGINT PRIMARY KEY, name TEXT NOT NULL UNIQUE, logo TEXT, - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL ); -CREATE TABLE organisation_admins ( +CREATE TYPE organisation_role AS ENUM ('User', 'Admin'); + +CREATE TABLE organisation_members ( id SERIAL PRIMARY KEY, organisation_id BIGINT NOT NULL, user_id BIGINT NOT NULL, - CONSTRAINT FK_organisation_admins_organisation + role organisation_role DEFAULT 'User' NOT NULL, + CONSTRAINT FK_organisation_members_organisation FOREIGN KEY(organisation_id) REFERENCES organisations(id) ON DELETE CASCADE ON UPDATE CASCADE ); -CREATE INDEX IDX_organisation_admins_organisation on organisation_admins (organisation_id); +CREATE INDEX IDX_organisation_admins_organisation on organisation_members (organisation_id); diff --git a/backend/migrations/20240406025537_create_campaigns.sql b/backend/migrations/20240406025537_create_campaigns.sql index 46bca9df..c49df429 100644 --- a/backend/migrations/20240406025537_create_campaigns.sql +++ b/backend/migrations/20240406025537_create_campaigns.sql @@ -6,8 +6,8 @@ CREATE TABLE campaigns ( description TEXT, starts_at TIMESTAMPTZ NOT NULL, ends_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_campaigns_organisations FOREIGN KEY(organisation_id) REFERENCES organisations(id) @@ -23,8 +23,8 @@ CREATE TABLE campaign_roles ( min_available INTEGER, max_available INTEGER, finalised BOOLEAN, - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_campaign_roles_campaign FOREIGN KEY(campaign_id) REFERENCES campaigns(id) diff --git a/backend/migrations/20240406031400_create_questions.sql b/backend/migrations/20240406031400_create_questions.sql index fa170813..050d07fe 100644 --- a/backend/migrations/20240406031400_create_questions.sql +++ b/backend/migrations/20240406031400_create_questions.sql @@ -7,8 +7,8 @@ CREATE TABLE questions ( required BOOLEAN, question_type question_type NOT NULL, campaign_id BIGINT NOT NULL, - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_questions_campaigns FOREIGN KEY(campaign_id) REFERENCES campaigns(id) diff --git a/backend/migrations/20240406031915_create_applications.sql b/backend/migrations/20240406031915_create_applications.sql index 91d9edcf..767abb92 100644 --- a/backend/migrations/20240406031915_create_applications.sql +++ b/backend/migrations/20240406031915_create_applications.sql @@ -6,8 +6,8 @@ CREATE TABLE applications ( user_id BIGINT NOT NULL, status application_status NOT NULL, private_status application_status NOT NULL, - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_applications_campaigns FOREIGN KEY(campaign_id) REFERENCES campaigns(id) @@ -95,8 +95,8 @@ CREATE TABLE application_ratings ( application_id BIGINT NOT NULL, rater_id BIGINT NOT NULL, rating INTEGER NOT NULL, - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_application_ratings_applications FOREIGN KEY(application_id) REFERENCES applications(id) diff --git a/backend/server/Cargo.toml b/backend/server/Cargo.toml index 84445fdc..9c4babe2 100644 --- a/backend/server/Cargo.toml +++ b/backend/server/Cargo.toml @@ -10,7 +10,7 @@ edition = "2021" tokio = { version = "1.34", features = ["macros", "rt-multi-thread"] } axum = { version = "0.7", features = ["macros"] } axum-extra = { version = "0.9", features = ["typed-header"] } -sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "time", "uuid"] } +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] } # Important secondary crates anyhow = "1.0" @@ -22,6 +22,8 @@ chrono = { version = "0.4", features = ["serde"] } oauth2 = "4.4" log = "0.4" uuid = { version = "1.5", features = ["serde", "v4"] } +rust-s3 = "0.34.0" rs-snowflake = "0.6" jsonwebtoken = "9.1" dotenvy = "0.15" + diff --git a/backend/server/src/handler/mod.rs b/backend/server/src/handler/mod.rs index 0e4a05d5..163c4759 100644 --- a/backend/server/src/handler/mod.rs +++ b/backend/server/src/handler/mod.rs @@ -1 +1,2 @@ pub mod auth; +pub mod organisation; diff --git a/backend/server/src/handler/organisation.rs b/backend/server/src/handler/organisation.rs new file mode 100644 index 00000000..e1f08100 --- /dev/null +++ b/backend/server/src/handler/organisation.rs @@ -0,0 +1,160 @@ +use crate::models; +use crate::models::app::AppState; +use crate::models::auth::SuperUser; +use crate::models::auth::{AuthUser, OrganisationAdmin}; +use crate::models::error::ChaosError; +use crate::models::organisation::{AdminToRemove, AdminUpdateList, NewOrganisation, Organisation}; +use crate::models::transaction::DBTransaction; +use crate::service; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +pub struct OrganisationHandler; + +impl OrganisationHandler { + pub async fn create( + State(state): State, + _user: SuperUser, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + Organisation::create( + data.admin, + data.name, + state.snowflake_generator, + &mut transaction.tx, + ) + .await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully created organisation")) + } + + pub async fn get( + State(state): State, + Path(id): Path, + _user: AuthUser, + ) -> Result { + let org = Organisation::get(id, &state.db).await?; + Ok((StatusCode::OK, Json(org))) + } + + pub async fn delete( + State(state): State, + Path(id): Path, + _user: SuperUser, + ) -> Result { + Organisation::delete(id, &state.db).await?; + Ok((StatusCode::OK, "Successfully deleted organisation")) + } + + pub async fn get_admins( + State(state): State, + Path(id): Path, + _user: SuperUser, + ) -> Result { + let members = Organisation::get_admins(id, &state.db).await?; + Ok((StatusCode::OK, Json(members))) + } + + pub async fn get_members( + State(state): State, + Path(id): Path, + _admin: OrganisationAdmin, + ) -> Result { + let members = Organisation::get_members(id, &state.db).await?; + Ok((StatusCode::OK, Json(members))) + } + + pub async fn update_admins( + State(state): State, + Path(id): Path, + _super_user: SuperUser, + mut transaction: DBTransaction<'_>, + Json(request_body): Json, + ) -> Result { + Organisation::update_admins(id, request_body.members, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully updated organisation members")) + } + + pub async fn update_members( + State(state): State, + mut transaction: DBTransaction<'_>, + Path(id): Path, + _admin: OrganisationAdmin, + Json(request_body): Json, + ) -> Result { + Organisation::update_members(id, request_body.members, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok((StatusCode::OK, "Successfully updated organisation members")) + } + + pub async fn remove_admin( + State(state): State, + Path(id): Path, + _super_user: SuperUser, + Json(request_body): Json, + ) -> Result { + Organisation::remove_admin(id, request_body.user_id, &state.db).await?; + + Ok(( + StatusCode::OK, + "Successfully removed member from organisation", + )) + } + + pub async fn remove_member( + State(state): State, + Path(id): Path, + _admin: OrganisationAdmin, + Json(request_body): Json, + ) -> Result { + Organisation::remove_member(id, request_body.user_id, &state.db).await?; + + Ok(( + StatusCode::OK, + "Successfully removed member from organisation", + )) + } + + pub async fn update_logo( + State(state): State, + Path(id): Path, + _admin: OrganisationAdmin, + ) -> Result { + let logo_url = Organisation::update_logo(id, &state.db, &state.storage_bucket).await?; + Ok((StatusCode::OK, Json(logo_url))) + } + + pub async fn get_campaigns( + State(state): State, + Path(id): Path, + _user: AuthUser, + ) -> Result { + let campaigns = Organisation::get_campaigns(id, &state.db).await?; + + Ok((StatusCode::OK, Json(campaigns))) + } + + pub async fn create_campaign( + State(mut state): State, + _admin: OrganisationAdmin, + Json(request_body): Json, + ) -> Result { + Organisation::create_campaign( + request_body.name, + request_body.description, + request_body.starts_at, + request_body.ends_at, + &state.db, + &mut state.snowflake_generator, + ) + .await?; + + Ok((StatusCode::OK, "Successfully created campaign")) + } +} diff --git a/backend/server/src/main.rs b/backend/server/src/main.rs index 8d91e4b0..38b2022c 100644 --- a/backend/server/src/main.rs +++ b/backend/server/src/main.rs @@ -1,11 +1,14 @@ +use crate::handler::auth::google_callback; +use crate::handler::organisation::OrganisationHandler; +use crate::models::storage::Storage; use anyhow::Result; -use axum::{routing::get, Router}; +use axum::routing::{get, patch, post, put}; +use axum::Router; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; use models::app::AppState; use snowflake::SnowflakeIdGenerator; use sqlx::postgres::PgPoolOptions; use std::env; -use crate::handler::auth::google_callback; mod handler; mod models; @@ -43,6 +46,9 @@ async fn main() -> Result<()> { // Initialise Snowflake Generator let snowflake_generator = SnowflakeIdGenerator::new(1, 1); + // Initialise S3 bucket + let storage_bucket = Storage::init_bucket(); + // Add all data to AppState let state = AppState { db: pool, @@ -52,11 +58,37 @@ async fn main() -> Result<()> { jwt_header, jwt_validator, snowflake_generator, + storage_bucket, }; let app = Router::new() .route("/", get(|| async { "Hello, World!" })) .route("/api/auth/callback/google", get(google_callback)) + .route("/api/v1/organisation", post(OrganisationHandler::create)) + .route( + "/api/v1/organisation/:id", + get(OrganisationHandler::get).delete(OrganisationHandler::delete), + ) + .route( + "/api/v1/organisation/:id/campaign", + get(OrganisationHandler::get_campaigns).post(OrganisationHandler::create_campaign), + ) + .route( + "/api/v1/organisation/:id/logo", + patch(OrganisationHandler::update_logo), + ) + .route( + "/api/v1/organisation/:id/member", + get(OrganisationHandler::get_members) + .put(OrganisationHandler::update_members) + .delete(OrganisationHandler::remove_member), + ) + .route( + "/api/v1/organisation/:id/admin", + get(OrganisationHandler::get_admins) + .put(OrganisationHandler::update_admins) + .delete(OrganisationHandler::remove_admin), + ) .with_state(state); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); diff --git a/backend/server/src/models/app.rs b/backend/server/src/models/app.rs index 9fbd2a8e..19ecfbff 100644 --- a/backend/server/src/models/app.rs +++ b/backend/server/src/models/app.rs @@ -1,5 +1,6 @@ use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; use reqwest::Client as ReqwestClient; +use s3::Bucket; use snowflake::SnowflakeIdGenerator; use sqlx::{Pool, Postgres}; @@ -12,4 +13,5 @@ pub struct AppState { pub jwt_header: Header, pub jwt_validator: Validation, pub snowflake_generator: SnowflakeIdGenerator, + pub storage_bucket: Bucket, } diff --git a/backend/server/src/models/auth.rs b/backend/server/src/models/auth.rs index c292f092..b5e9c298 100644 --- a/backend/server/src/models/auth.rs +++ b/backend/server/src/models/auth.rs @@ -2,13 +2,11 @@ use crate::models::app::AppState; use crate::models::error::ChaosError; use crate::service::auth::is_super_user; use crate::service::jwt::decode_auth_token; -use axum::extract::{FromRef, FromRequestParts}; +use crate::service::organisation::user_is_admin; +use axum::extract::{FromRef, FromRequestParts, Path}; use axum::http::request::Parts; use axum::response::{IntoResponse, Redirect, Response}; -use axum::{ - async_trait, - RequestPartsExt, -}; +use axum::{async_trait, RequestPartsExt}; use axum_extra::{headers::Cookie, TypedHeader}; use serde::{Deserialize, Serialize}; @@ -106,3 +104,43 @@ where Err(ChaosError::Unauthorized) } } + +pub struct OrganisationAdmin { + pub user_id: i64, +} + +#[async_trait] +impl FromRequestParts for OrganisationAdmin +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let decoding_key = &app_state.decoding_key; + let jwt_validator = &app_state.jwt_validator; + let TypedHeader(cookies) = parts + .extract::>() + .await + .map_err(|_| ChaosError::NotLoggedIn)?; + + let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedIn)?; + + let claims = + decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; + + let pool = &app_state.db; + let user_id = claims.sub; + + let Path(organisation_id) = parts + .extract::>() + .await + .map_err(|_| ChaosError::BadRequest)?; + + user_is_admin(user_id, organisation_id, pool).await?; + + Ok(OrganisationAdmin { user_id }) + } +} diff --git a/backend/server/src/models/campaign.rs b/backend/server/src/models/campaign.rs new file mode 100644 index 00000000..a139900c --- /dev/null +++ b/backend/server/src/models/campaign.rs @@ -0,0 +1,12 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct Campaign { + pub id: i64, + pub name: String, + pub description: Option, + pub cover_image: Option, + pub starts_at: DateTime, + pub ends_at: DateTime, +} diff --git a/backend/server/src/models/error.rs b/backend/server/src/models/error.rs index 86c3004c..3a2869e3 100644 --- a/backend/server/src/models/error.rs +++ b/backend/server/src/models/error.rs @@ -16,6 +16,9 @@ pub enum ChaosError { #[error("Forbidden operation")] ForbiddenOperation, + #[error("Bad request")] + BadRequest, + #[error("SQLx error")] DatabaseError(#[from] sqlx::Error), @@ -23,7 +26,16 @@ pub enum ChaosError { ReqwestError(#[from] reqwest::Error), #[error("OAuth2 error")] - OAuthError(#[from] oauth2::RequestTokenError, oauth2::StandardErrorResponse>) + OAuthError( + #[from] + oauth2::RequestTokenError< + oauth2::reqwest::Error, + oauth2::StandardErrorResponse, + >, + ), + + #[error("S3 error")] + StorageError(#[from] s3::error::S3Error), } /// Implementation for converting errors into responses. Manages error code and message returned. @@ -32,13 +44,16 @@ impl IntoResponse for ChaosError { match self { ChaosError::NotLoggedIn => Redirect::temporary("/auth/google").into_response(), ChaosError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), - ChaosError::ForbiddenOperation => (StatusCode::FORBIDDEN, "Forbidden operation").into_response(), + ChaosError::ForbiddenOperation => { + (StatusCode::FORBIDDEN, "Forbidden operation").into_response() + } + ChaosError::BadRequest => (StatusCode::BAD_REQUEST, "Bad request").into_response(), ChaosError::DatabaseError(db_error) => match db_error { // We only care about the RowNotFound error, as others are miscellaneous DB errors. sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Not found").into_response(), _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(), }, - _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() + _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(), } } } diff --git a/backend/server/src/models/mod.rs b/backend/server/src/models/mod.rs index b992e0f9..e5a5edb3 100644 --- a/backend/server/src/models/mod.rs +++ b/backend/server/src/models/mod.rs @@ -1,4 +1,8 @@ pub mod app; pub mod auth; +pub mod campaign; pub mod error; +pub mod organisation; +pub mod storage; +pub mod transaction; pub mod user; diff --git a/backend/server/src/models/organisation.rs b/backend/server/src/models/organisation.rs new file mode 100644 index 00000000..d21bd50d --- /dev/null +++ b/backend/server/src/models/organisation.rs @@ -0,0 +1,345 @@ +use crate::models::campaign::Campaign; +use crate::models::error::ChaosError; +use crate::models::storage::Storage; +use chrono::{DateTime, Utc}; +use s3::Bucket; +use serde::{Deserialize, Serialize}; +use snowflake::SnowflakeIdGenerator; +use sqlx::{FromRow, Pool, Postgres, Transaction}; +use std::ops::DerefMut; +use uuid::Uuid; + +#[derive(Deserialize, Serialize, Clone, FromRow, Debug)] +pub struct Organisation { + pub id: i64, + pub name: String, + pub logo: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub campaigns: Vec, // Awaiting Campaign to be complete - remove comment once done + pub organisation_admins: Vec, +} + +#[derive(Deserialize, Serialize)] +pub struct NewOrganisation { + pub name: String, + pub admin: i64, +} + +#[derive(Deserialize, Serialize)] +pub struct OrganisationDetails { + pub id: i64, + pub name: String, + pub logo: Option, + pub created_at: DateTime, +} + +#[derive(Deserialize, Serialize, sqlx::Type, Clone)] +#[sqlx(type_name = "organisation_role", rename_all = "PascalCase")] +pub enum OrganisationRole { + User, + Admin, +} + +#[derive(Deserialize, Serialize, FromRow)] +pub struct Member { + pub id: i64, + pub name: String, + pub role: OrganisationRole, +} + +#[derive(Deserialize, Serialize)] +pub struct MemberList { + pub members: Vec, +} + +#[derive(Deserialize, Serialize)] +pub struct AdminUpdateList { + pub members: Vec, +} + +#[derive(Deserialize, Serialize)] +pub struct AdminToRemove { + pub user_id: i64, +} + +impl Organisation { + pub async fn create( + admin_id: i64, + name: String, + mut snowflake_generator: SnowflakeIdGenerator, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let id = snowflake_generator.generate(); + + sqlx::query!( + " + INSERT INTO organisations (id, name) + VALUES ($1, $2) + ", + id, + name + ) + .execute(transaction.deref_mut()) + .await?; + + sqlx::query!( + " + INSERT INTO organisation_members (organisation_id, user_id, role) + VALUES ($1, $2, $3) + ", + id, + admin_id, + OrganisationRole::Admin as OrganisationRole + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(()) + } + + pub async fn get(id: i64, pool: &Pool) -> Result { + let organisation = sqlx::query_as!( + OrganisationDetails, + " + SELECT id, name, logo, created_at + FROM organisations + WHERE id = $1 + ", + id + ) + .fetch_one(pool) + .await?; + + Ok(organisation) + } + + pub async fn delete(id: i64, pool: &Pool) -> Result<(), ChaosError> { + sqlx::query!( + " + DELETE FROM organisations WHERE id = $1 + ", + id + ) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn get_admins( + organisation_id: i64, + pool: &Pool, + ) -> Result { + let admin_list = sqlx::query_as!( + Member, + " + SELECT organisation_members.user_id as id, organisation_members.role AS \"role: OrganisationRole\", users.name from organisation_members + LEFT JOIN users on users.id = organisation_members.user_id + WHERE organisation_members.organisation_id = $1 AND organisation_members.role = $2 + ", + organisation_id, + OrganisationRole::Admin as OrganisationRole + ) + .fetch_all(pool) + .await?; + + Ok(MemberList { + members: admin_list, + }) + } + + pub async fn get_members( + organisation_id: i64, + pool: &Pool, + ) -> Result { + let admin_list = sqlx::query_as!( + Member, + " + SELECT organisation_members.user_id as id, organisation_members.role AS \"role: OrganisationRole\", users.name from organisation_members + LEFT JOIN users on users.id = organisation_members.user_id + WHERE organisation_members.organisation_id = $1 + ", + organisation_id + ) + .fetch_all(pool) + .await?; + + Ok(MemberList { + members: admin_list, + }) + } + + pub async fn update_admins( + organisation_id: i64, + admin_id_list: Vec, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + sqlx::query!( + "DELETE FROM organisation_members WHERE organisation_id = $1 AND role = $2", + organisation_id, + OrganisationRole::Admin as OrganisationRole + ) + .execute(transaction.deref_mut()) + .await?; + + for admin_id in admin_id_list { + sqlx::query!( + " + INSERT INTO organisation_members (organisation_id, user_id, role) + VALUES ($1, $2, $3) + ", + organisation_id, + admin_id, + OrganisationRole::Admin as OrganisationRole + ) + .execute(transaction.deref_mut()) + .await?; + } + + Ok(()) + } + + pub async fn update_members( + organisation_id: i64, + member_id_list: Vec, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + sqlx::query!( + "DELETE FROM organisation_members WHERE organisation_id = $1 AND role = $2", + organisation_id, + OrganisationRole::User as OrganisationRole + ) + .execute(transaction.deref_mut()) + .await?; + + for member_id in member_id_list { + sqlx::query!( + " + INSERT INTO organisation_members (organisation_id, user_id, role) + VALUES ($1, $2, $3) + ", + organisation_id, + member_id, + OrganisationRole::User as OrganisationRole + ) + .execute(transaction.deref_mut()) + .await?; + } + + Ok(()) + } + + pub async fn remove_admin( + organisation_id: i64, + admin_to_remove: i64, + pool: &Pool, + ) -> Result<(), ChaosError> { + sqlx::query!( + " + UPDATE organisation_members SET role = $3 WHERE user_id = $1 AND organisation_id = $2 + ", + admin_to_remove, + organisation_id, + OrganisationRole::User as OrganisationRole + ) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn remove_member( + organisation_id: i64, + user_id: i64, + pool: &Pool, + ) -> Result<(), ChaosError> { + sqlx::query!( + " + DELETE FROM organisation_members WHERE user_id = $1 AND organisation_id = $2 + ", + user_id, + organisation_id + ) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn update_logo( + id: i64, + pool: &Pool, + storage_bucket: &Bucket, + ) -> Result { + let dt = Utc::now(); + + let logo_id = Uuid::new_v4().to_string(); // TODO: Change db type to UUID + let current_time = dt; + sqlx::query!( + " + UPDATE organisations + SET logo = $2, updated_at = $3 + WHERE id = $1 + ", + id, + logo_id, + current_time + ) + .execute(pool) + .await?; + + // TODO: Handle MIME type on FE and BE and handle in S3 upload + let image_id = Uuid::new_v4(); + let upload_url = + Storage::generate_put_url(format!("/{id}/{image_id}"), storage_bucket).await?; + + Ok(upload_url) + } + + pub async fn get_campaigns( + organisation_id: i64, + pool: &Pool, + ) -> Result, ChaosError> { + let campaigns = sqlx::query_as!( + Campaign, + " + SELECT id, name, cover_image, description, starts_at, ends_at + FROM campaigns + WHERE organisation_id = $1 + ", + organisation_id + ) + .fetch_all(pool) + .await?; + + Ok(campaigns) + } + + pub async fn create_campaign( + name: String, + description: Option, + starts_at: DateTime, + ends_at: DateTime, + pool: &Pool, + snowflake_id_generator: &mut SnowflakeIdGenerator, + ) -> Result<(), ChaosError> { + let new_campaign_id = snowflake_id_generator.real_time_generate(); + + sqlx::query!( + " + INSERT INTO campaigns (id, name, description, starts_at, ends_at) + VALUES ($1, $2, $3, $4, $5) + ", + new_campaign_id, + name, + description, + starts_at, + ends_at + ) + .execute(pool) + .await?; + + Ok(()) + } +} diff --git a/backend/server/src/models/storage.rs b/backend/server/src/models/storage.rs new file mode 100644 index 00000000..53b63b96 --- /dev/null +++ b/backend/server/src/models/storage.rs @@ -0,0 +1,54 @@ +use crate::models::error::ChaosError; +use s3::creds::Credentials; +use s3::{Bucket, BucketConfiguration, Region}; +use std::env; + +pub struct Storage; + +impl Storage { + pub fn init_bucket() -> Bucket { + let bucket_name = env::var("S3_BUCKET_NAME") + .expect("Error getting S3 BUCKET NAME") + .to_string(); + let access_key = env::var("S3_ACCESS_KEY") + .expect("Error getting S3 CREDENTIALS") + .to_string(); + let secret_key = env::var("S3_SECRET_KEY") + .expect("Error getting S3 CREDENTIALS") + .to_string(); + let endpoint = env::var("S3_ENDPOINT") + .expect("Error getting S3 ENDPOINT") + .to_string(); + let region_name = env::var("S3_REGION_NAME") + .expect("Error getting S3 REGION NAME") + .to_string(); + + let credentials = Credentials::new( + Option::from(access_key.as_str()), + Option::from(secret_key.as_str()), + None, + None, + None, + ) + .unwrap(); + + let region = Region::Custom { + region: region_name, + endpoint, + }; + + let config = BucketConfiguration::default(); + + let mut bucket = Bucket::new(&*bucket_name, region, credentials).unwrap(); + // TODO: Change depending on style used by provider + // bucket.set_path_style(); + + bucket + } + + pub async fn generate_put_url(path: String, bucket: &Bucket) -> Result { + let url = bucket.presign_put(path, 3600, None).await?; + + Ok(url) + } +} diff --git a/backend/server/src/models/transaction.rs b/backend/server/src/models/transaction.rs new file mode 100644 index 00000000..529f6320 --- /dev/null +++ b/backend/server/src/models/transaction.rs @@ -0,0 +1,27 @@ +use crate::models::app::AppState; +use crate::models::error::ChaosError; +use axum::async_trait; +use axum::extract::{FromRef, FromRequestParts}; +use axum::http::request::Parts; +use sqlx::{Postgres, Transaction}; + +pub struct DBTransaction<'a> { + pub tx: Transaction<'a, Postgres>, +} + +#[async_trait] +impl FromRequestParts for DBTransaction<'_> +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(_: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + + Ok(DBTransaction { + tx: app_state.db.begin().await?, + }) + } +} diff --git a/backend/server/src/models/user.rs b/backend/server/src/models/user.rs index 1f99f52f..82c8f042 100644 --- a/backend/server/src/models/user.rs +++ b/backend/server/src/models/user.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize, sqlx::Type, Clone)] -#[sqlx(type_name = "UserRole", rename_all = "PascalCase")] +#[sqlx(type_name = "user_role", rename_all = "PascalCase")] pub enum UserRole { User, SuperUser, diff --git a/backend/server/src/service/auth.rs b/backend/server/src/service/auth.rs index 7da5a35b..cf136cfd 100644 --- a/backend/server/src/service/auth.rs +++ b/backend/server/src/service/auth.rs @@ -1,7 +1,7 @@ +use crate::models::user::UserRole; use anyhow::Result; use snowflake::SnowflakeIdGenerator; use sqlx::{Pool, Postgres}; -use crate::models::user::UserRole; /// Checks if a user exists in DB based on given email address. If so, their user_id is returned. /// Otherwise, a new user is created in the DB, and the new id is returned. diff --git a/backend/server/src/service/jwt.rs b/backend/server/src/service/jwt.rs index c7db35cb..bddce804 100644 --- a/backend/server/src/service/jwt.rs +++ b/backend/server/src/service/jwt.rs @@ -1,5 +1,5 @@ -use jsonwebtoken::{decode, encode, EncodingKey, Header, Validation}; use jsonwebtoken::DecodingKey; +use jsonwebtoken::{decode, encode, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use uuid::Uuid; diff --git a/backend/server/src/service/mod.rs b/backend/server/src/service/mod.rs index 6e59a7bb..5e708b6b 100644 --- a/backend/server/src/service/mod.rs +++ b/backend/server/src/service/mod.rs @@ -1,3 +1,4 @@ pub mod auth; pub mod jwt; pub mod oauth2; +pub mod organisation; diff --git a/backend/server/src/service/organisation.rs b/backend/server/src/service/organisation.rs new file mode 100644 index 00000000..a29a1716 --- /dev/null +++ b/backend/server/src/service/organisation.rs @@ -0,0 +1,28 @@ +use crate::models::campaign::Campaign; +use crate::models::error::ChaosError; +use crate::models::organisation::{Member, MemberList, OrganisationDetails, OrganisationRole}; +use chrono::{DateTime, Utc}; +use snowflake::SnowflakeIdGenerator; +use sqlx::{Pool, Postgres, Transaction}; +use std::ops::DerefMut; +use uuid::Uuid; + +pub async fn user_is_admin( + user_id: i64, + organisation_id: i64, + pool: &Pool, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM organisation_members WHERE organisation_id = $1 AND user_id = $2 AND role = 'Admin')", + organisation_id, + user_id + ) + .fetch_one(pool) + .await?.exists.expect("`exists` should always exist in this query result"); + + if !is_admin { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +}