diff --git a/.env.example b/.env.example index cb4412d0..0c0f1c23 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,7 @@ HOSTNAME="---" ACTIX_KEEP_ALIVE=120 MAX_DB_CONNECTION_POOL_SIZE=3 ENABLE_TENANT_AND_SCOPE=true -TENANTS=dev,test +TENANTS=dev,test,superposition TENANT_MIDDLEWARE_EXCLUSION_LIST="/health,/assets/favicon.ico,/pkg/frontend.js,/pkg,/pkg/frontend_bg.wasm,/pkg/tailwind.css,/pkg/style.css,/assets,/admin,/oidc/login,/admin/organisations,/organisations,/organisations/switch/{organisation_id},/" SERVICE_PREFIX="" SERVICE_NAME="CAC" @@ -28,4 +28,5 @@ AUTH_PROVIDER=DISABLED OIDC_CLIENT_ID=superposition OIDC_CLIENT_SECRET=superposition_secret OIDC_TOKEN_ENDPOINT_FORMAT="http://localhost:8081/realms//protocol/openid-connect/token" -OIDC_ISSUER_ENDPOINT_FORMAT="http://http://localhost:8081/realms/" \ No newline at end of file +OIDC_ISSUER_ENDPOINT_FORMAT="http://http://localhost:8081/realms/" +WORKER_ID=1 diff --git a/Cargo.lock b/Cargo.lock index 2a6a4d60..d1f02685 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2520,6 +2520,18 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idgenerator" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab32f68e287887b5f783055dac63971ae26c76be1ebde166c64d5bf5bdd5b6a" +dependencies = [ + "chrono", + "once_cell", + "parking_lot", + "thiserror", +] + [[package]] name = "idna" version = "0.5.0" @@ -4574,16 +4586,20 @@ version = "0.1.0" dependencies = [ "actix-files", "actix-web", + "anyhow", "aws-sdk-kms", "cac_toml", "cfg-if", + "chrono", "context_aware_config", + "diesel", "dotenv", "env_logger", "experimentation_platform", "fred", "frontend", "futures-util", + "idgenerator", "leptos", "leptos_actix", "log", diff --git a/crates/service_utils/src/service/types.rs b/crates/service_utils/src/service/types.rs index ead4fd22..86cd6143 100644 --- a/crates/service_utils/src/service/types.rs +++ b/crates/service_utils/src/service/types.rs @@ -76,6 +76,7 @@ impl FromStr for AppEnv { pub enum AppScope { CAC, EXPERIMENTATION, + SUPERPOSITION, } impl FromRequest for AppScope { type Error = Error; diff --git a/crates/superposition/Cargo.toml b/crates/superposition/Cargo.toml index b1fdfa41..1974aabe 100644 --- a/crates/superposition/Cargo.toml +++ b/crates/superposition/Cargo.toml @@ -8,16 +8,20 @@ edition = "2021" [dependencies] actix-files = { version = "0.6" } actix-web = { workspace = true } +anyhow = { workspace = true } aws-sdk-kms = { workspace = true } cac_toml = { path = "../cac_toml" } cfg-if = { workspace = true } +chrono = { workspace = true } context_aware_config = { path = "../context_aware_config" } +diesel = { workspace = true } dotenv = "0.15.0" env_logger = "0.8" experimentation_platform = { path = "../experimentation_platform" } fred = { workspace = true, optional = true } frontend = { path = "../frontend" } futures-util = { workspace = true } +idgenerator = "2.0.0" leptos = { workspace = true } leptos_actix = { version = "0.6.11" } log = { workspace = true } diff --git a/crates/superposition/src/main.rs b/crates/superposition/src/main.rs index 146a7d61..464b3efd 100644 --- a/crates/superposition/src/main.rs +++ b/crates/superposition/src/main.rs @@ -1,7 +1,9 @@ #![deny(unused_crate_dependencies)] mod app_state; mod auth; +mod organisation; +use idgenerator::{IdGeneratorOptions, IdInstance}; use std::{collections::HashSet, io::Result, time::Duration}; use actix_files::Files; @@ -44,6 +46,15 @@ async fn main() -> Result<()> { let service_prefix: String = get_from_env_unsafe("SERVICE_PREFIX").expect("SERVICE_PREFIX is not set"); + let worker_id: u32 = get_from_env_unsafe("WORKER_ID").expect("WORKER_ID is not set"); + + let options = IdGeneratorOptions::new() + .worker_id(worker_id) + .worker_id_bit_len(8) + .seq_bit_len(12); + + IdInstance::init(options).expect("Failed to initialize ID generator"); + /* Reading from a env returns a String at best we cannot obtain a &'static str from it, which seems logical as it not known at compiletime, and there is no straightforward way to do this. @@ -181,6 +192,11 @@ async fn main() -> Result<()> { AppExecutionScopeMiddlewareFactory::new(AppScope::EXPERIMENTATION), ), ) + .service( + scope("/organisation") + .wrap(AppExecutionScopeMiddlewareFactory::new(AppScope::SUPERPOSITION)) + .service(organisation::endpoints()), + ) /***************************** UI Routes ******************************/ .route("/fxn/{tail:.*}", leptos_actix::handle_server_fns()) // serve JS/WASM/CSS from `pkg` diff --git a/crates/superposition/src/organisation.rs b/crates/superposition/src/organisation.rs new file mode 100644 index 00000000..a34458e6 --- /dev/null +++ b/crates/superposition/src/organisation.rs @@ -0,0 +1,3 @@ +mod handlers; +pub mod types; +pub use handlers::endpoints; diff --git a/crates/superposition/src/organisation/handlers.rs b/crates/superposition/src/organisation/handlers.rs new file mode 100644 index 00000000..b713f7e8 --- /dev/null +++ b/crates/superposition/src/organisation/handlers.rs @@ -0,0 +1,146 @@ +use actix_web::{ + get, post, + web::{self, Json, Query}, + HttpResponse, Scope, +}; +use chrono::Utc; +use diesel::prelude::*; +use idgenerator::IdInstance; +use service_utils::service::types::DbConnection; +use superposition_types::database::{ + models::organisation::{OrgStatus, Organisation}, + schema::organisations::dsl::organisations, +}; +use superposition_types::{ + custom_query::PaginationParams, result as superposition, PaginatedResponse, User, +}; + +use super::types::{CreateOrganisationRequest, CreateOrganisationResponse}; + +pub fn endpoints() -> Scope { + Scope::new("") + .service(create_organisation) + .service(list_organisations) + .service(get_organisation) +} + +#[post("")] +pub async fn create_organisation( + req: web::Json, + db_conn: DbConnection, + user: User, +) -> superposition::Result { + let DbConnection(mut conn) = db_conn; + + // Generating a numeric ID from IdInstance and prefixing it with `orgid` + let numeric_id = IdInstance::next_id(); + let org_id = format!("orgid{}", numeric_id); + let now = Utc::now().naive_utc(); + + let new_org = Organisation { + id: org_id.clone(), + name: req.name.clone(), + country_code: req.country_code.clone(), + contact_email: req.contact_email.clone(), + contact_phone: req.contact_phone.clone(), + created_by: user.get_username(), + admin_email: req.admin_email.clone(), + status: OrgStatus::PendingKyb, + sector: req.sector.clone(), + created_at: now, + updated_at: now, + updated_by: user.get_username(), + }; + + diesel::insert_into(organisations) + .values(&new_org) + .execute(&mut conn) + .map_err(|e| { + log::error!("Failed to insert new organisation: {:?}", e); + superposition::AppError::UnexpectedError(anyhow::anyhow!( + "Failed to create organisation" + )) + })?; + + let mut http_resp = HttpResponse::Created(); + Ok(http_resp.json(CreateOrganisationResponse { org_id })) +} + +#[get("/{org_id}")] +pub async fn get_organisation( + org_id: web::Path, + db_conn: DbConnection, +) -> superposition::Result { + let DbConnection(mut conn) = db_conn; + + let org = organisations + .find(org_id.as_str()) + .first::(&mut conn) + .map_err(|e| { + log::error!("Failed to fetch organisation {}: {:?}", org_id, e); + match e { + diesel::result::Error::NotFound => superposition::AppError::NotFound( + format!("Organisation {} not found", org_id), + ), + _ => superposition::AppError::UnexpectedError(anyhow::anyhow!( + "Failed to fetch organisation" + )), + } + })?; + + Ok(HttpResponse::Ok().json(org)) +} + +#[get("/list")] +pub async fn list_organisations( + db_conn: DbConnection, + filters: Query, +) -> superposition::Result>> { + use superposition_types::database::schema::organisations::dsl::*; + let DbConnection(mut conn) = db_conn; + log::info!("list_organisations"); + let result = + conn.transaction::<_, superposition::AppError, _>(|transaction_conn| { + // If all parameter is true, return all organisations + if let Some(true) = filters.all { + let result: Vec = organisations + .order(created_at.desc()) + .get_results(transaction_conn)?; + log::info!("organisations: {organisations:?}"); + return Ok(PaginatedResponse { + total_pages: 1, + total_items: result.len() as i64, + data: result, + }); + } + + // Get total count of organisations + let total_items: i64 = organisations.count().get_result(transaction_conn)?; + + // Set up pagination + let limit = filters.count.unwrap_or(10); + let mut builder = organisations + .into_boxed() + .order(created_at.desc()) + .limit(limit); + + // Apply offset if page is specified + if let Some(page) = filters.page { + let offset = (page - 1) * limit; + builder = builder.offset(offset); + } + + // Get paginated results + let data: Vec = builder.load(transaction_conn)?; + + let total_pages = (total_items as f64 / limit as f64).ceil() as i64; + + Ok(PaginatedResponse { + total_pages, + total_items, + data: data, + }) + })?; + + Ok(Json(result)) +} diff --git a/crates/superposition/src/organisation/types.rs b/crates/superposition/src/organisation/types.rs new file mode 100644 index 00000000..85450899 --- /dev/null +++ b/crates/superposition/src/organisation/types.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +// Request payload for creating an organisation +#[derive(Deserialize)] +pub struct CreateOrganisationRequest { + pub country_code: Option, + pub contact_email: Option, + pub contact_phone: Option, + pub admin_email: String, + pub name: String, + pub sector: Option, +} + +// Response type to include `org_id` +#[derive(Serialize)] +pub struct CreateOrganisationResponse { + pub org_id: String, +} diff --git a/crates/superposition_types/Cargo.toml b/crates/superposition_types/Cargo.toml index 34e25f4f..65a6ffe1 100644 --- a/crates/superposition_types/Cargo.toml +++ b/crates/superposition_types/Cargo.toml @@ -21,7 +21,7 @@ log = { workspace = true } regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -strum_macros = { workspace = true, optional = true } +strum_macros = { workspace = true } superposition_derives = { path = "../superposition_derives", optional = true } thiserror = { version = "1.0.57", optional = true } uuid = { workspace = true } @@ -36,7 +36,7 @@ diesel_derives = [ disable_db_data_validation = [] result = ["dep:diesel", "dep:anyhow", "dep:thiserror", "dep:actix-web"] server = ["dep:actix-web"] -experimentation = ["dep:strum_macros"] +experimentation = [] [lints] workspace = true diff --git a/crates/superposition_types/migrations/2024-12-19-095525_create_organisation_table/down.sql b/crates/superposition_types/migrations/2024-12-19-095525_create_organisation_table/down.sql new file mode 100644 index 00000000..30638532 --- /dev/null +++ b/crates/superposition_types/migrations/2024-12-19-095525_create_organisation_table/down.sql @@ -0,0 +1,8 @@ +-- down.sql +DROP INDEX IF EXISTS superposition.idx_organisation_admin_email; +DROP INDEX IF EXISTS superposition.idx_organisation_created_at; +DROP INDEX IF EXISTS superposition.idx_organisation_status; +DROP INDEX IF EXISTS superposition.idx_organisation_contact_email; +DROP TABLE IF EXISTS superposition.organisation; +DROP TYPE IF EXISTS superposition.org_status; +DROP SCHEMA IF EXISTS superposition; \ No newline at end of file diff --git a/crates/superposition_types/migrations/2024-12-19-095525_create_organisation_table/up.sql b/crates/superposition_types/migrations/2024-12-19-095525_create_organisation_table/up.sql new file mode 100644 index 00000000..0ac94e44 --- /dev/null +++ b/crates/superposition_types/migrations/2024-12-19-095525_create_organisation_table/up.sql @@ -0,0 +1,26 @@ +-- up.sql +CREATE SCHEMA IF NOT EXISTS superposition; + +CREATE TYPE superposition.org_status AS ENUM ('ACTIVE', 'INACTIVE', 'PENDING_KYB'); + +CREATE TABLE IF NOT EXISTS superposition.organisations ( + id VARCHAR(30) PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + country_code VARCHAR(10), + contact_email VARCHAR(255), + contact_phone VARCHAR(15), + created_by VARCHAR(255) NOT NULL, + admin_email VARCHAR(255) NOT NULL, + status superposition.org_status NOT NULL DEFAULT 'ACTIVE', + sector VARCHAR(100), + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL, + updated_by VARCHAR(255) NOT NULL +); + +-- Indexes for optimizing queries +CREATE INDEX IF NOT EXISTS idx_organisation_contact_email ON superposition.organisations (contact_email); +CREATE INDEX IF NOT EXISTS idx_organisation_status ON superposition.organisations (status); +CREATE INDEX IF NOT EXISTS idx_organisation_created_at ON superposition.organisations (created_at); +CREATE INDEX IF NOT EXISTS idx_organisation_admin_email ON superposition.organisations (admin_email); + diff --git a/crates/superposition_types/src/database/models.rs b/crates/superposition_types/src/database/models.rs index f3bca3b3..90458a41 100644 --- a/crates/superposition_types/src/database/models.rs +++ b/crates/superposition_types/src/database/models.rs @@ -1,3 +1,4 @@ pub mod cac; #[cfg(feature = "experimentation")] pub mod experimentation; +pub mod organisation; diff --git a/crates/superposition_types/src/database/models/organisation.rs b/crates/superposition_types/src/database/models/organisation.rs new file mode 100644 index 00000000..b258c159 --- /dev/null +++ b/crates/superposition_types/src/database/models/organisation.rs @@ -0,0 +1,49 @@ +#[cfg(feature = "diesel_derives")] +use super::super::schema::*; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "diesel_derives")] +use diesel::{AsChangeset, Insertable, QueryId, Queryable, Selectable}; + +#[derive( + Debug, Clone, Copy, PartialEq, Deserialize, Serialize, strum_macros::Display, +)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[cfg_attr( + feature = "diesel_derives", + derive(diesel_derive_enum::DbEnum, QueryId) +)] +#[cfg_attr(feature = "diesel_derives", DbValueStyle = "SCREAMING_SNAKE_CASE")] +#[cfg_attr( + feature = "diesel_derives", + ExistingTypePath = "crate::database::schema::sql_types::OrgStatus" +)] +pub enum OrgStatus { + Active, + Inactive, + PendingKyb, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +#[cfg_attr( + feature = "diesel_derives", + derive(Queryable, Selectable, Insertable, AsChangeset) +)] +#[cfg_attr(feature = "diesel_derives", diesel(check_for_backend(diesel::pg::Pg)))] +#[cfg_attr(feature = "diesel_derives", diesel(primary_key(id)))] +#[cfg_attr(feature = "diesel_derives", diesel(treat_none_as_null = true))] +pub struct Organisation { + pub id: String, + pub name: String, + pub country_code: Option, + pub contact_email: Option, + pub contact_phone: Option, + pub created_by: String, + pub admin_email: String, + pub status: OrgStatus, + pub sector: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub updated_by: String, +} diff --git a/crates/superposition_types/src/database/schema.rs b/crates/superposition_types/src/database/schema.rs index 5c19a230..fad44535 100644 --- a/crates/superposition_types/src/database/schema.rs +++ b/crates/superposition_types/src/database/schema.rs @@ -4,6 +4,9 @@ pub mod sql_types { #[derive(diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "experiment_status_type"))] pub struct ExperimentStatusType; + #[derive(diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "org_status"))] + pub struct OrgStatus; } diesel::table! { @@ -646,6 +649,34 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::OrgStatus; + + organisations (id) { + #[max_length = 30] + id -> Varchar, + name -> Text, + #[max_length = 10] + country_code -> Nullable, + #[max_length = 255] + contact_email -> Nullable, + #[max_length = 15] + contact_phone -> Nullable, + #[max_length = 255] + created_by -> Varchar, + #[max_length = 255] + admin_email -> Varchar, + status -> OrgStatus, + #[max_length = 100] + sector -> Nullable, + created_at -> Timestamp, + updated_at -> Timestamp, + #[max_length = 255] + updated_by -> Varchar, + } +} + diesel::table! { type_templates (type_name) { type_name -> Text, @@ -710,5 +741,6 @@ diesel::allow_tables_to_appear_in_same_query!( event_log_y2026m12, experiments, functions, + organisations, type_templates, ); diff --git a/flake.nix b/flake.nix index 186044be..974a0c50 100644 --- a/flake.nix +++ b/flake.nix @@ -29,7 +29,7 @@ devShells.default = pkgs.mkShell { inputsFrom = [ self'.devShells.rust - self'.devShells.haskell + # self'.devShells.haskell config.pre-commit.devShell ]; # Add your devShell tools here