diff --git a/latent-backend/Cargo.lock b/latent-backend/Cargo.lock index 8b8ae08..e0a9af1 100644 --- a/latent-backend/Cargo.lock +++ b/latent-backend/Cargo.lock @@ -86,9 +86,11 @@ dependencies = [ name = "api" version = "0.1.0" dependencies = [ + "chrono", "db", "dotenv", "env_logger", + "jsonwebtoken", "log", "poem", "poem-openapi", @@ -98,6 +100,7 @@ dependencies = [ "sha2", "sqlx", "thiserror 2.0.11", + "time", "tokio", ] @@ -226,7 +229,10 @@ checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", "windows-targets 0.52.6", ] @@ -387,9 +393,11 @@ dependencies = [ name = "db" version = "0.1.0" dependencies = [ + "chrono", "dotenv", "log", "serde", + "serde_json", "sqlx", "tokio", "uuid", @@ -704,8 +712,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1217,6 +1227,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" +dependencies = [ + "base64 0.21.7", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1368,6 +1393,16 @@ dependencies = [ "libc", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -1515,6 +1550,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "pem" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +dependencies = [ + "base64 0.22.1", + "serde", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1846,6 +1891,21 @@ dependencies = [ "uncased", ] +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsa" version = "0.9.7" @@ -2051,6 +2111,18 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.11", + "time", +] + [[package]] name = "slab" version = "0.4.9" @@ -2118,6 +2190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0" dependencies = [ "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -2197,6 +2270,7 @@ dependencies = [ "bitflags 2.7.0", "byteorder", "bytes", + "chrono", "crc", "digest", "dotenvy", @@ -2240,6 +2314,7 @@ dependencies = [ "base64 0.22.1", "bitflags 2.7.0", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -2276,6 +2351,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -2772,6 +2848,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.4" diff --git a/latent-backend/api/.env.example b/latent-backend/api/.env.example index c7c665a..08199ef 100644 --- a/latent-backend/api/.env.example +++ b/latent-backend/api/.env.example @@ -1,4 +1,5 @@ DB_URL=postgres://postgres:mysecretpassword@localhost:5432 TWILIO_AUTH_TOKEN="<>" TWILIO_ACCOUNT_SID="<>" -TWILIO_PHONE_NUMBER="<>" \ No newline at end of file +TWILIO_PHONE_NUMBER="<>" +PORT=8080 diff --git a/latent-backend/api/Cargo.toml b/latent-backend/api/Cargo.toml index 3aa9f20..eafb145 100644 --- a/latent-backend/api/Cargo.toml +++ b/latent-backend/api/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] db = { path = "../db" } dotenv = "0.15.0" +jsonwebtoken = "9.3.0" poem = "3.1.3" poem-openapi = { version = "5.1.2", features = ["swagger-ui"] } serde = "1.0.217" @@ -16,4 +17,6 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "tls-native-t reqwest = { version = "0.11.13", features = ["json"] } env_logger = "0.10" log = "0.4" -sha2 = "0.10" \ No newline at end of file +sha2 = "0.10" +time = { version = "0.3", features = ["macros"] } +chrono = { version = "0.4", features = ["serde"] } diff --git a/latent-backend/api/src/error.rs b/latent-backend/api/src/error.rs index f2b9cc9..8a23895 100644 --- a/latent-backend/api/src/error.rs +++ b/latent-backend/api/src/error.rs @@ -1,4 +1,7 @@ +use db::DBError; use poem_openapi::{payload::Json, ApiResponse, Object}; +use poem::error::Error as PoemError; +use jsonwebtoken::errors::Error as JwtError; #[derive(Debug, Object)] pub struct ErrorBody { @@ -30,6 +33,10 @@ pub enum AppError { /// Bad request (400) #[oai(status = 400)] BadRequest(Json), + + /// Admin Not Found (411) + #[oai(status = 411)] + AdminNotFound(Json), } impl From for AppError { @@ -44,3 +51,33 @@ impl From for AppError { } } } + +impl From for AppError { + fn from(err: PoemError) -> Self { + AppError::InternalServerError(Json(ErrorBody { + message: err.to_string(), + })) + } +} + +impl From for AppError { + fn from(err: JwtError) -> Self { + AppError::InternalServerError(Json(ErrorBody { + message: format!("JWT error: {}", err), + })) + } +} + +impl From for AppError { + fn from(err: DBError) -> Self { + match err { + DBError::NotFound(msg) => AppError::NotFound(Json(ErrorBody { + message: msg, + })), + DBError::InvalidInput(msg) => AppError::BadRequest(Json(ErrorBody { + message: msg, + })), + DBError::DatabaseError(sqlx_err) => sqlx_err.into(), // Convert sqlx::Error to AppError + } + } +} diff --git a/latent-backend/api/src/main.rs b/latent-backend/api/src/main.rs index a9418f8..718ed18 100644 --- a/latent-backend/api/src/main.rs +++ b/latent-backend/api/src/main.rs @@ -1,57 +1,80 @@ use poem::{ - listener::TcpListener, - middleware::Cors, - EndpointExt, Route, Server, + listener::TcpListener, middleware::Cors, EndpointExt, Result, + Route, Server, }; use poem_openapi::OpenApiService; use std::sync::Arc; mod error; +mod middleware; mod routes; mod utils; use db::Db; use dotenv::dotenv; +use std::env; #[derive(Clone)] pub struct AppState { db: Arc, } -pub struct Api; - #[tokio::main] async fn main() -> Result<(), std::io::Error> { // Load environment variables dotenv().ok(); - + // Initialize logger env_logger::init_from_env(env_logger::Env::default().default_filter_or("info")); + // Read port from environment variable + let port = env::var("PORT").unwrap_or_else(|_| "8080".to_string()); + let server_url = format!("http://localhost:{}/api/v1", port); + // Create and initialize database let db = Db::new().await; db.init().await.expect("Failed to initialize database"); let db = Arc::new(db); // Create API service - let api_service = OpenApiService::new(Api, "Latent Booking", "1.0") - .server("http://localhost:3000/api/v1"); - + let api_service = + OpenApiService::new(routes::user::UserApi, "Latent Booking", "1.0") + .server(format!("{}/user", server_url)); + + let admin_api_service = + OpenApiService::new(routes::admin::AdminApi, "Admin Latent Booking", "1.0") + .server(format!("{}/admin", server_url)); + + let event_api_service = + OpenApiService::new(routes::event::EventApi, "Event Latent Booking", "1.0") + .server(format!("{}/admin/event", server_url)); + // Create Swagger UI let ui = api_service.swagger_ui(); // Create route with CORS - let app = Route::new() - .nest("/api/v1", api_service) - .nest("/docs", ui) - .with(Cors::new()) - .data(AppState { db }); - - println!("Server running at http://localhost:3000"); - println!("API docs at http://localhost:3000/docs"); - + let mut app = Route::new() + .nest("/api/v1/user", api_service) + .nest("/api/v1/admin", admin_api_service) + .nest("/api/v1/admin/event", event_api_service) + .nest("/docs", ui); + + if cfg!(debug_assertions) { + let test_api_service = + OpenApiService::new(routes::test::TestApi, "Test Latent Booking", "1.0") + .server(format!("{}/test", server_url)); + + app = app.nest("/api/v1/test", test_api_service); + println!("Test routes enabled (development mode)"); + } + + let app = app.with(Cors::new()).data(AppState { db }); + + println!("Server running at {}", server_url); + println!("API docs at {}/docs", server_url); + // Start server - Server::new(TcpListener::bind("0.0.0.0:3000")) + Server::new(TcpListener::bind(format!("0.0.0.0:{}", port))) .run(app) .await } diff --git a/latent-backend/api/src/middleware/admin.rs b/latent-backend/api/src/middleware/admin.rs new file mode 100644 index 0000000..b2d82ba --- /dev/null +++ b/latent-backend/api/src/middleware/admin.rs @@ -0,0 +1,92 @@ +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use poem::{Endpoint, EndpointExt, Middleware, Request, Result}; +use poem_openapi::payload; +use serde::{Deserialize, Serialize}; + +use crate::{ + error::AppError, + utils::config::{admin_jwt_password, superadmin_jwt_password}, +}; + +#[derive(Debug, Serialize, Deserialize)] +struct Claims { + sub: String, // Subject (e.g., user ID) + exp: usize, // Expiration time +} + +#[derive(Debug, Clone)] +pub struct TokenData { + pub id: String, +} + +pub struct JwtMiddleware { + secret: String, // Secret key for JWT verification +} + +impl JwtMiddleware { + /// Create a new instance of `JwtMiddleware` with the given secret key + pub fn new(secret: String) -> Self { + Self { secret } + } +} +impl Middleware for JwtMiddleware { + type Output = JwtMiddlewareImpl; + + fn transform(&self, ep: E) -> Self::Output { + JwtMiddlewareImpl { + ep, + secret: self.secret.clone(), + } + } +} + +pub struct JwtMiddlewareImpl { + ep: E, + secret: String, +} + +impl poem::Endpoint for JwtMiddlewareImpl { + type Output = E::Output; + + async fn call(&self, mut req: Request) -> Result { + let token = req + .headers() + .get("Authorization") + .and_then(|value| value.to_str().ok()); + + if let Some(token) = token { + let decoding_key = DecodingKey::from_secret(self.secret.as_bytes()); + let validation = Validation::new(Algorithm::HS256); + + match decode::(token, &decoding_key, &validation) { + Ok(token_data) => { + req.extensions_mut().insert(TokenData { + id: token_data.claims.sub, + }); + self.ep.call(req).await + } + Err(_) => Err( + AppError::Unauthorized(payload::Json(crate::error::ErrorBody { + message: "Unauthorized".to_string(), + })) + .into(), + ), + } + } else { + Err( + AppError::Unauthorized(payload::Json(crate::error::ErrorBody { + message: "Unauthorized".to_string(), + })) + .into(), + ) + } + } +} + +pub fn admin_middleware(ep: impl Endpoint) -> impl Endpoint { + ep.with(JwtMiddleware::new(admin_jwt_password())) +} + +pub fn superadmin_middleware(ep: impl Endpoint) -> impl Endpoint { + ep.with(JwtMiddleware::new(superadmin_jwt_password())) +} diff --git a/latent-backend/api/src/middleware/mod.rs b/latent-backend/api/src/middleware/mod.rs new file mode 100644 index 0000000..92918b0 --- /dev/null +++ b/latent-backend/api/src/middleware/mod.rs @@ -0,0 +1 @@ +pub mod admin; diff --git a/latent-backend/api/src/routes/admin.rs b/latent-backend/api/src/routes/admin.rs new file mode 100644 index 0000000..936a4c9 --- /dev/null +++ b/latent-backend/api/src/routes/admin.rs @@ -0,0 +1,184 @@ +use crate::{ + error::AppError, + middleware::admin::{admin_middleware, superadmin_middleware}, + utils::{config, jwt::create_jwt, totp, twilio}, + AppState, +}; + +use poem::web::{Data, Json}; +use poem_openapi::{payload, Object, OpenApi}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +struct Claims { + sub: String, // Subject (e.g., user ID) + exp: usize, // Expiration time +} + +#[derive(Debug, Deserialize, Serialize, Object)] +struct VerifyAdminResponse { + token: String, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct SignInRequest { + number: String, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct SignInResponse { + message: String, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct SignInVerify { + number: String, + totp: String, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct Location { + id: String, + name: String, + description: String, + imageUrl: String, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +pub struct LocationResponse { + locations: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +pub struct CreateLocation { + name: String, + description: String, + imageUrl: String, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +pub struct CreateLocationResponse { + message: String, + id: String, +} + +pub struct AdminApi; + +#[OpenApi] +impl AdminApi { + /// Sign in existing admin + #[oai(path = "/signin", method = "post")] + async fn sign_in( + &self, + body: Json, + state: Data<&AppState>, + ) -> poem::Result, AppError> { + let number = body.0.number; + + println!("Signing in admin with number: {}", number); + + let admin_result = state.db.get_admin_by_number(&number).await; + + if let Err(sqlx::Error::RowNotFound) = admin_result { + return Err(AppError::AdminNotFound(payload::Json( + crate::error::ErrorBody { + message: "Admin not found".to_string(), + }, + ))); + } + + let _admin = admin_result?; + // Generate and send OTP + let otp = totp::get_token(&number, "ADMIN_AUTH"); + if cfg!(not(debug_assertions)) { + twilio::send_message( + &format!("Your admin OTP for signing in to Latent is {}", otp), + &number, + ) + .await + .map_err(|_| { + AppError::InternalServerError(payload::Json(crate::error::ErrorBody { + message: "Failed to send OTP".to_string(), + })) + })?; + } else { + println!("Development mode: OTP is {}", otp); + } + + Ok(payload::Json(SignInResponse { + message: "OTP sent successfully".to_string(), + })) + } + + /// Verify sign in with OTP + #[oai(path = "/signin/verify", method = "post")] + async fn sign_in_verify( + &self, + body: Json, + state: Data<&AppState>, + ) -> poem::Result, AppError> { + let SignInVerify { number, totp: otp } = body.0; + + // Verify OTP + if cfg!(not(debug_assertions)) && !totp::verify_token(&number, "ADMIN_AUTH", &otp) { + return Err(AppError::InvalidCredentials(payload::Json( + crate::error::ErrorBody { + message: "Invalid OTP".to_string(), + }, + ))); + } + + let user_id = state.db.verify_admin_signin(number).await?; + + let jwt_token = create_jwt(user_id, 3600, &config::admin_jwt_password())?; + + Ok(payload::Json(VerifyAdminResponse { token: jwt_token })) + } + + #[oai(path = "/location", method = "get", transform = "admin_middleware")] + pub async fn get_location( + &self, + state: Data<&AppState>, + ) -> poem::Result, AppError> { + let db_locations = state.db.get_location().await?; + + let locations = db_locations + .iter() + .map(|l| Location { + id: l.id.to_string(), + name: l.name.clone(), + description: l.description.clone(), + imageUrl: l.image_url.clone(), + }) + .collect(); + + Ok(payload::Json(LocationResponse { locations })) + } + + #[oai( + path = "/location", + method = "post", + transform = "superadmin_middleware" + )] + pub async fn create_location( + &self, + body: Json, + state: Data<&AppState>, + ) -> poem::Result, AppError> { + let CreateLocation { + name, + description, + imageUrl, + } = body.0; + + let location = state + .db + .create_location(name, description, imageUrl) + .await?; + + Ok(payload::Json(CreateLocationResponse { + message: "Location created successfully".to_string(), + id: location.id.to_string(), + })) + } +} diff --git a/latent-backend/api/src/routes/event.rs b/latent-backend/api/src/routes/event.rs new file mode 100644 index 0000000..a7ba5dc --- /dev/null +++ b/latent-backend/api/src/routes/event.rs @@ -0,0 +1,358 @@ +use crate::{ + error::AppError, + middleware::admin::{admin_middleware, TokenData}, + AppState, +}; + +use db::{CreateEventInput, SeatTypeInput, SeatUpdateInput, UpdateEventInput}; +use poem::web::{Data, Json}; +use poem_openapi::{param::Path, payload, Object, OpenApi}; +use serde::{Deserialize, Serialize}; +use sqlx::types::{time::PrimitiveDateTime, Uuid}; +use time::macros::format_description; + +#[derive(Debug, Serialize, Deserialize, Object)] +struct CreateEvent { + name: String, + description: String, + banner: String, + locationId: String, + startTime: String, + seats: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct CreateEventResponse { + message: String, + id: String, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct SeatType { + name: String, + description: String, + price: i32, + capacity: i32, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct UpdateEvent { + name: String, + description: String, + start_time: String, + location_id: String, + banner: String, + published: bool, + ended: bool, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct EventResponse { + id: String, + name: String, + description: String, + start_time: String, + location_id: String, + banner: String, + published: bool, + ended: bool, + seatTypes: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct SingleEventWithSeatsResponse { + message: String, + event: EventResponse, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct EventWithSeatsResponse { + message: String, + event: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct UpdateSeat { + id: Option, + name: String, + description: String, + price: i32, + capacity: i32, +} + +impl UpdateSeat { + fn to_seat_update_input(&self) -> Result { + let id = match &self.id { + Some(id) => Some(Uuid::parse_str(id).map_err(|_| { + AppError::BadRequest(payload::Json(crate::error::ErrorBody { + message: format!("Invalid seat ID: {}", id), + })) + })?), + None => None, + }; + + Ok(SeatUpdateInput { + id, + name: self.name.clone(), + description: self.description.clone(), + price: self.price, + capacity: self.capacity, + }) + } +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct UpdateSeatsInput { + seats: Vec, +} + +pub struct EventApi; + +#[OpenApi] +impl EventApi { + /// Create a new event + #[oai(path = "/", method = "post", transform = "admin_middleware")] + async fn create_admin_event( + &self, + admin_id: Data<&TokenData>, + body: Json, + state: Data<&AppState>, + ) -> poem::Result, AppError> { + println!("Admin ID from token: {:?}", admin_id.id); + + let location_id = Uuid::parse_str(body.0.locationId.as_str()).map_err(|_| { + AppError::InternalServerError(payload::Json(crate::error::ErrorBody { + message: "Invalid location ID".to_string(), + })) + })?; + + let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); + let start_time = + PrimitiveDateTime::parse(body.0.startTime.as_str(), &format).map_err(|_| { + AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { + message: "Invalid start time".to_string(), + })) + })?; + + let admin_id = Uuid::parse_str(admin_id.id.as_str()).map_err(|_| { + AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { + message: "Invalid admin ID".to_string(), + })) + })?; + + let event = state + .db + .create_event(CreateEventInput { + name: body.0.name, + description: body.0.description, + banner: body.0.banner, + admin_id, + location_id, + start_time, + seats: body + .0 + .seats + .iter() + .map(|st| SeatTypeInput { + name: st.name.clone(), + description: st.description.clone(), + price: st.price, + capacity: st.capacity, + }) + .collect(), + }) + .await?; + + Ok(payload::Json(CreateEventResponse { + message: "Event created successfully".to_string(), + id: event.id.to_string(), + })) + } + + /// Update event metadata + #[oai( + path = "/metadata/:event_id", + method = "put", + transform = "admin_middleware" + )] + async fn update_admin_event( + &self, + event_id: Path, + admin_id: Data<&TokenData>, + body: Json, + state: Data<&AppState>, + ) -> poem::Result, AppError> { + let location_id = Uuid::parse_str(body.0.location_id.as_str()).map_err(|_| { + AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { + message: "Invalid location ID".to_string(), + })) + })?; + + let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); + let start_time = + PrimitiveDateTime::parse(body.0.start_time.as_str(), &format).map_err(|_| { + AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { + message: "Invalid start time".to_string(), + })) + })?; + + let admin_id = Uuid::parse_str(admin_id.id.as_str()).map_err(|_| { + AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { + message: "Invalid admin ID".to_string(), + })) + })?; + + let event_id = Uuid::parse_str(event_id.0.as_str()).map_err(|_| { + AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { + message: "Invalid admin ID".to_string(), + })) + })?; + let event = state + .db + .update_event_metadata(UpdateEventInput { + name: body.0.name, + description: body.0.description, + banner: body.0.banner, + admin_id, + location_id, + start_time, + published: body.0.published, + ended: body.0.ended, + event_id, + }) + .await?; + + Ok(payload::Json(CreateEventResponse { + message: "Event updated successfully".to_string(), + id: event.id.to_string(), + })) + } + + /// List events for an admin + #[oai(path = "/", method = "get", transform = "admin_middleware")] + async fn list_events( + &self, + admin_id: Data<&TokenData>, + state: Data<&AppState>, + ) -> poem::Result, AppError> { + let admin_id = Uuid::parse_str(admin_id.id.as_str()).map_err(|_| { + AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { + message: "Invalid admin ID".to_string(), + })) + })?; + + let events = state.db.get_events(admin_id).await?; + let event_responses: Vec = events + .into_iter() + .map(|event| EventResponse { + id: event.id.to_string(), + name: event.name, + description: event.description, + banner: event.banner, + location_id: event.location_id.to_string(), + start_time: event.start_time.to_string(), + published: event.published, + ended: event.ended, + seatTypes: event + .seat_types + .map(|seat_types_value| { + serde_json::from_value::>(seat_types_value) + .unwrap_or_else(|_| Vec::new()) // Handle deserialization errors + }) + .unwrap_or_default(), + }) + .collect(); + + Ok(payload::Json(EventWithSeatsResponse { + message: "Events fetched successfully".to_string(), + event: event_responses, + })) + } + + /// Get a specific event + #[oai(path = "/:event_id", method = "get", transform = "admin_middleware")] + async fn get_event_by_id( + &self, + event_id: Path, + admin_id: Data<&TokenData>, + state: Data<&AppState>, + ) -> poem::Result, AppError> { + let admin_id = Uuid::parse_str(admin_id.id.as_str()).map_err(|_| { + AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { + message: "Invalid admin ID".to_string(), + })) + })?; + + let event_id = Uuid::parse_str(event_id.0.as_str()).map_err(|_| { + AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { + message: "Invalid admin ID".to_string(), + })) + })?; + + let events = state.db.get_event(event_id, admin_id).await?; + let event_responses = EventResponse { + id: events.id.to_string(), + name: events.name, + description: events.description, + banner: events.banner, + location_id: events.location_id.to_string(), + start_time: events.start_time.to_string(), + published: events.published, + ended: events.ended, + seatTypes: events + .seat_types + .map(|seat_types_value| { + serde_json::from_value::>(seat_types_value) + .unwrap_or_else(|_| Vec::new()) // Handle deserialization errors + }) + .unwrap_or_default(), + }; + + Ok(payload::Json(SingleEventWithSeatsResponse { + message: "Events fetched successfully".to_string(), + event: event_responses, + })) + } + + /// Update seats for an event + #[oai( + path = "/seats/:event_id", + method = "put", + transform = "admin_middleware" + )] + async fn update_seats( + &self, + event_id: Path, + admin_id: Data<&TokenData>, + body: Json, + state: Data<&AppState>, + ) -> poem::Result, AppError> { + let admin_id = Uuid::parse_str(admin_id.id.as_str()).map_err(|_| { + AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { + message: "Invalid admin ID".to_string(), + })) + })?; + + let event_id = Uuid::parse_str(event_id.0.as_str()).map_err(|_| { + AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { + message: "Invalid event ID".to_string(), + })) + })?; + + let seats: Result, AppError> = body + .0 + .seats + .into_iter() + .map(|seat| seat.to_seat_update_input()) + .collect(); + + let seats = seats?; + + state + .db + .update_seats(event_id, admin_id, seats) + .await?; + + Ok(payload::Json("Seats updated successfully".to_string())) + } +} diff --git a/latent-backend/api/src/routes/mod.rs b/latent-backend/api/src/routes/mod.rs index 018ff2e..9a0ae06 100644 --- a/latent-backend/api/src/routes/mod.rs +++ b/latent-backend/api/src/routes/mod.rs @@ -1 +1,6 @@ -pub mod user; \ No newline at end of file +pub mod user; +pub mod admin; +pub mod event; + +#[cfg(debug_assertions)] +pub mod test; diff --git a/latent-backend/api/src/routes/test.rs b/latent-backend/api/src/routes/test.rs new file mode 100644 index 0000000..6601aea --- /dev/null +++ b/latent-backend/api/src/routes/test.rs @@ -0,0 +1,89 @@ +use crate::{error::AppError, utils::{config, jwt::create_jwt}, AppState}; +use db::AdminType; +use poem::web::{Data, Json}; +use poem_openapi::{payload, Object, OpenApi}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Object)] +struct CreateTestUser { + number: String, + name: String, +} + +#[derive(Debug, Deserialize, Serialize, Object)] +struct CreateTestUserResponse { + message: String, + token: String, +} + +pub struct TestApi; + +#[OpenApi] +impl TestApi { + /// Create a test admin + #[oai(path = "/create-admin", method = "post")] + async fn create_admin( + &self, + body: Json, + state: Data<&AppState>, + ) -> poem::Result, AppError> { + let number = body.0.number; + let name = body.0.name; + let user = state + .db + .create_test_admin(number.clone(), name, AdminType::Creator) + .await?; + + + let user_id = user.id.to_string(); + let jwt_token = create_jwt(user_id, 3600, &config::admin_jwt_password())?; + + Ok(payload::Json(CreateTestUserResponse { + message: "Test Admin created successfully".to_string(), + token: jwt_token, + })) + } + + /// Create a test super-admin + #[oai(path = "/create-super-admin", method = "post")] + async fn create_super_admin( + &self, + body: Json, + state: Data<&AppState>, + ) -> poem::Result, AppError> { + let number = body.0.number; + let name = body.0.name; + let user = state + .db + .create_test_admin(number.clone(), name, AdminType::SuperAdmin) + .await?; + + let user_id = user.id.to_string(); + let jwt_token = create_jwt(user_id, 3600, &config::superadmin_jwt_password())?; + + Ok(payload::Json(CreateTestUserResponse { + message: "Test Super Admin created successfully".to_string(), + token: jwt_token, + })) + } + + /// Create a test user + #[oai(path = "/create-user", method = "post")] + async fn create_test_user( + &self, + body: Json, + state: Data<&AppState>, + ) -> poem::Result, AppError> { + let number = body.0.number; + let name = body.0.name; + let user = state.db.create_test_user(number.clone(), name).await?; + + let user_id = user.id.to_string(); + let jwt_token = create_jwt(user_id, 3600, &config::jwt_password())?; + + Ok(payload::Json(CreateTestUserResponse { + message: "Test User created successfully".to_string(), + token: jwt_token, + })) + } +} diff --git a/latent-backend/api/src/routes/user.rs b/latent-backend/api/src/routes/user.rs index b14f4d4..9c2976c 100644 --- a/latent-backend/api/src/routes/user.rs +++ b/latent-backend/api/src/routes/user.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use poem::web::{Data, Json}; use poem_openapi::{OpenApi, Object, payload}; -use crate::{error::AppError, Api, AppState, utils::{totp, twilio}}; +use crate::{error::AppError, AppState, utils::{totp, twilio}}; #[derive(Debug, Serialize, Deserialize, Object)] struct CreateUser { @@ -17,7 +17,7 @@ struct CreateUserResponse { #[derive(Debug, Serialize, Deserialize, Object)] struct CreateUserVerify { number: String, - totp: String, + otp: String, name: String, } @@ -39,11 +39,13 @@ struct SignInResponse { #[derive(Debug, Serialize, Deserialize, Object)] struct SignInVerify { number: String, - totp: String, + otp: String, } +pub struct UserApi; + #[OpenApi] -impl Api { +impl UserApi { /// Create a new user #[oai(path = "/signup", method = "post")] async fn create_user(&self, body: Json, state: Data<&AppState>) -> poem::Result, AppError> { @@ -75,17 +77,19 @@ impl Api { body: Json, state: Data<&AppState> ) -> poem::Result, AppError> { - let CreateUserVerify { number, totp: otp, name } = body.0; + let CreateUserVerify { number, otp, name } = body.0; // Verify OTP if cfg!(not(debug_assertions)) { if !totp::verify_token(&number, "AUTH", &otp) { + println!("Invalid OTP"); return Err(AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { message: "Invalid OTP".to_string(), }))); } } + println!("Verifying user with number: {}", number); let token = state.db.verify_user(number, name).await?; Ok(payload::Json(VerifyUserResponse { token })) @@ -100,8 +104,18 @@ impl Api { ) -> poem::Result, AppError> { let number = body.0.number; - let _user = state.db.get_user_by_number(&number).await?; + let user_result = state.db.get_user_by_number(&number).await; + if let Err(sqlx::Error::RowNotFound) = user_result { + return Err(AppError::AdminNotFound(payload::Json( + crate::error::ErrorBody { + message: "User not found".to_string(), + }, + ))); + } + + let _user = user_result?; + // Generate and send OTP let otp = totp::get_token(&number, "AUTH"); if cfg!(not(debug_assertions)) { @@ -126,7 +140,7 @@ impl Api { body: Json, state: Data<&AppState> ) -> poem::Result, AppError> { - let SignInVerify { number, totp: otp } = body.0; + let SignInVerify { number, otp } = body.0; // Verify OTP if cfg!(not(debug_assertions)) { @@ -141,4 +155,4 @@ impl Api { Ok(payload::Json(VerifyUserResponse { token })) } -} \ No newline at end of file +} diff --git a/latent-backend/api/src/utils/config.rs b/latent-backend/api/src/utils/config.rs new file mode 100644 index 0000000..254cc93 --- /dev/null +++ b/latent-backend/api/src/utils/config.rs @@ -0,0 +1,14 @@ +use std::env; + +#[allow(dead_code)] +pub fn jwt_password() -> String { + env::var("JWT_PASSWORD").unwrap_or_else(|_| "123random".to_string()) +} + +pub fn admin_jwt_password() -> String { + env::var("ADMIN_JWT_PASSWORD").unwrap_or_else(|_| "123random".to_string()) +} + +pub fn superadmin_jwt_password() -> String { + env::var("SUPERADMIN_JWT_PASSWORD").unwrap_or_else(|_| "123random".to_string()) +} diff --git a/latent-backend/api/src/utils/jwt.rs b/latent-backend/api/src/utils/jwt.rs new file mode 100644 index 0000000..d46766b --- /dev/null +++ b/latent-backend/api/src/utils/jwt.rs @@ -0,0 +1,42 @@ +use jsonwebtoken::{encode, EncodingKey, Header}; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; +use crate::error::AppError; +use poem_openapi::payload; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, // Subject (e.g., user ID) + pub exp: usize, // Expiration time +} + +/// Generates a JWT token for the given user ID and expiration time. +pub fn create_jwt( + user_id: String, + expiration_seconds: usize, + secret_key: &str, +) -> Result { + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| { + AppError::InternalServerError(payload::Json(crate::error::ErrorBody { + message: "Failed to get current time".to_string(), + })) + })? + .as_secs() as usize; + + let exp = current_time + expiration_seconds; + + let claims = Claims { + sub: user_id, + exp, + }; + + let jwt_token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret_key.as_bytes()), + )?; + + Ok(jwt_token) +} diff --git a/latent-backend/api/src/utils/mod.rs b/latent-backend/api/src/utils/mod.rs index ab598b5..b4992da 100644 --- a/latent-backend/api/src/utils/mod.rs +++ b/latent-backend/api/src/utils/mod.rs @@ -1,2 +1,4 @@ pub mod totp; -pub mod twilio; \ No newline at end of file +pub mod twilio; +pub mod config; +pub mod jwt; diff --git a/latent-backend/api/test.sh b/latent-backend/api/test.sh index 5ac1010..f447ba8 100755 --- a/latent-backend/api/test.sh +++ b/latent-backend/api/test.sh @@ -1,7 +1,10 @@ #!/bin/bash +PORT=${PORT:-8080} +BASE_URL="http://localhost:${PORT}" + echo "1. Creating new user..." -curl -X POST http://localhost:3000/api/v1/signup \ +curl -X POST "$BASE_URL/api/v1/user/signup" \ -H "Content-Type: application/json" \ -d '{"number": "9729302411"}' | jq @@ -9,7 +12,7 @@ echo -e "\nPress Enter to continue with signup verification..." read echo "2. Verifying signup..." -curl -X POST http://localhost:3000/api/v1/signup/verify \ +curl -X POST "$BASE_URL/api/v1/user/signup/verify" \ -H "Content-Type: application/json" \ -d '{ "number": "9729302411", @@ -21,7 +24,7 @@ echo -e "\nPress Enter to continue with signin..." read echo "3. Signing in..." -curl -X POST http://localhost:3000/api/v1/signin \ +curl -X POST "$BASE_URL/api/v1/user/signin" \ -H "Content-Type: application/json" \ -d '{"number": "9729302411"}' | jq @@ -29,9 +32,9 @@ echo -e "\nPress Enter to continue with signin verification..." read echo "4. Verifying signin..." -curl -X POST http://localhost:3000/api/v1/signin/verify \ +curl -X POST "$BASE_URL/api/v1/user/signin/verify" \ -H "Content-Type: application/json" \ -d '{ "number": "9729302411", "totp": "123456" - }' | jq \ No newline at end of file + }' | jq diff --git a/latent-backend/db/Cargo.toml b/latent-backend/db/Cargo.toml index 088da88..6323138 100644 --- a/latent-backend/db/Cargo.toml +++ b/latent-backend/db/Cargo.toml @@ -4,9 +4,11 @@ version = "0.1.0" edition = "2021" [dependencies] -sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "time", "tls-native-tls"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "time", "tls-native-tls", "chrono"] } tokio = { version = "1.43.0", features = ["full"] } serde = "1.0.217" uuid = { version = "1.6", features = ["v4", "serde"] } dotenv = "0.15.0" log = "0.4" +chrono = { version = "0.4", features = ["serde"] } +serde_json = "1.0" diff --git a/latent-backend/db/src/admin.rs b/latent-backend/db/src/admin.rs new file mode 100644 index 0000000..405fee3 --- /dev/null +++ b/latent-backend/db/src/admin.rs @@ -0,0 +1,59 @@ +use crate::Db; +use log::info; +use serde::{Deserialize, Serialize}; +use sqlx::Error; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(sqlx::Type, Debug, Serialize, Deserialize)] +#[sqlx(type_name = "admin_type")] +pub enum AdminType { + SuperAdmin, + Creator, +} + +#[derive(FromRow, Serialize, Deserialize)] +pub struct Admin { + pub id: Uuid, + pub number: String, + pub name: String, + pub verified: bool, + #[sqlx(rename = "type")] + pub admin_type: AdminType, +} + +impl Db { + pub async fn get_admin_by_number(&self, phone_number: &str) -> Result { + info!("Fetching admin with number: {}", phone_number); + + let result = sqlx::query_as::<_, Admin>("SELECT * FROM admins WHERE number = $1") + .bind(phone_number) + .fetch_one(&self.client) + .await; + + match result { + Ok(admin) => { + info!("Admin found with id: {}", admin.id); + Ok(admin) + } + Err(e) => { + eprintln!("Database error occurred: {}", e); + Err(e) + } + } + //info!("Admin found with id: {}", admin.id); + //Ok(admin) + } + + pub async fn verify_admin_signin(&self, phone_number: String) -> Result { + info!("Verifying signin for admin with number: {}", phone_number); + + let admin = sqlx::query_as::<_, Admin>("SELECT * FROM admins WHERE number = $1") + .bind(phone_number) + .fetch_one(&self.client) + .await?; + + info!("Signin verified for admin with id: {}", admin.id); + Ok(admin.id.to_string()) + } +} diff --git a/latent-backend/db/src/event.rs b/latent-backend/db/src/event.rs new file mode 100644 index 0000000..26984a3 --- /dev/null +++ b/latent-backend/db/src/event.rs @@ -0,0 +1,367 @@ +use crate::Db; +use chrono::DateTime; +use chrono::Utc; +use log::info; +use serde::{Deserialize, Serialize}; +use sqlx::types::time::OffsetDateTime; +use sqlx::types::time::PrimitiveDateTime; +use sqlx::Error; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(FromRow, Serialize, Deserialize)] +pub struct Event { + pub id: Uuid, +} + +#[derive(Debug)] +pub struct SeatTypeInput { + pub name: String, + pub description: String, + pub price: i32, + pub capacity: i32, +} + +#[derive(Debug)] +pub struct CreateEventInput { + pub name: String, + pub description: String, + pub banner: String, + pub admin_id: Uuid, + pub location_id: Uuid, + pub start_time: PrimitiveDateTime, + pub seats: Vec, +} + +#[derive(Debug)] +pub struct UpdateEventInput { + pub name: String, + pub description: String, + pub banner: String, + pub admin_id: Uuid, + pub event_id: Uuid, + pub location_id: Uuid, + pub start_time: PrimitiveDateTime, + pub published: bool, + pub ended: bool, +} + +#[derive(FromRow, Serialize, Deserialize)] +pub struct EventWithSeats { + pub id: Uuid, + pub name: String, + pub description: String, + pub banner: String, + pub admin_id: Uuid, + pub location_id: Uuid, + pub start_time: DateTime, + pub published: bool, + pub ended: bool, + pub seat_types: Option, +} + +#[derive(Debug)] +pub struct SeatUpdateInput { + pub id: Option, + pub name: String, + pub description: String, + pub price: i32, + pub capacity: i32, +} + +#[derive(Debug)] +pub enum DBError { + NotFound(String), + InvalidInput(String), + DatabaseError(sqlx::Error), +} + +impl From for DBError { + fn from(err: sqlx::Error) -> Self { + DBError::DatabaseError(err) + } +} + +impl Db { + pub async fn create_event(&self, input: CreateEventInput) -> Result { + info!("Creating new event"); + + let seat_names: Vec = input.seats.iter().map(|st| st.name.clone()).collect(); + let seat_descriptions: Vec = input + .seats + .iter() + .map(|st| st.description.clone()) + .collect(); + let seat_prices: Vec = input.seats.iter().map(|st| st.price).collect(); + let seat_capacities: Vec = input.seats.iter().map(|st| st.capacity).collect(); + + let event = sqlx::query_as::<_, Event>( + r#" + INSERT INTO events (id, name, description, banner, admin_id, location_id, start_time) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id + "#, + ) + .bind(Uuid::new_v4()) + .bind(input.name) + .bind(input.description) + .bind(input.banner) + .bind(input.admin_id) + .bind(input.location_id) + .bind(input.start_time) + .fetch_one(&self.client) + .await?; + + // Insert seats only if the array is not empty + if !input.seats.is_empty() { + sqlx::query( + r#" + INSERT INTO seat_types (id, name, description, event_id, price, capacity) + SELECT + uuid_generate_v4() AS id, + name, + description, + $1 AS event_id, + price, + capacity + FROM UNNEST($2::text[], $3::text[], $4::int[], $5::int[]) AS t(name, description, price, capacity) + "#, + ) + .bind(event.id) + .bind(seat_names) + .bind(seat_descriptions) + .bind(seat_prices) + .bind(seat_capacities) + .execute(&self.client) + .await?; + } + + info!("Event and seats created/updated successfully"); + Ok(event) + } + + pub async fn update_event_metadata(&self, input: UpdateEventInput) -> Result { + info!("Updating event metadata"); + + let event = sqlx::query_as::<_, Event>( + r#" + UPDATE events + SET + name = $1, + description = $2, + start_time = $3, + location_id = $4, + banner = $5, + published = $6, + ended = $7 + WHERE id = $8 AND admin_id = $9 + RETURNING id + "#, + ) + .bind(input.name) + .bind(input.description) + .bind(input.start_time) + .bind(input.location_id) + .bind(input.banner) + .bind(input.published) + .bind(input.ended) + .bind(input.event_id) + .bind(input.admin_id) + .fetch_one(&self.client) + .await?; + + info!("Event metadata updated successfully"); + Ok(event) + } + + pub async fn get_events(&self, admin_id: Uuid) -> Result, Error> { + info!("Fetching events"); + let raw_events = sqlx::query_as::<_, EventWithSeats>( + r#" + SELECT + events.*, + json_agg( + json_build_object( + 'id', seat_types.id, + 'name', seat_types.name, + 'description', seat_types.description, + 'price', seat_types.price, + 'capacity', seat_types.capacity + ) + ) FILTER (WHERE seat_types.id IS NOT NULL) AS seat_types + FROM events + LEFT JOIN seat_types ON events.id = seat_types.event_id + WHERE events.admin_id = $1 + GROUP BY events.id + "#, + ) + .bind(admin_id) + .fetch_all(&self.client) + .await?; + let events = raw_events + .into_iter() + .map(|raw_event| { + let seat_types = raw_event + .seat_types + .map(|value| serde_json::from_value(value).unwrap_or_default()) + .unwrap_or_default(); + + EventWithSeats { + id: raw_event.id, + name: raw_event.name, + description: raw_event.description, + banner: raw_event.banner, + admin_id: raw_event.admin_id, + location_id: raw_event.location_id, + start_time: raw_event.start_time, + published: raw_event.published, + ended: raw_event.ended, + seat_types: Some(seat_types), + } + }) + .collect(); + + info!("Events fetched successfully"); + Ok(events) + } + + pub async fn get_event(&self, event_id: Uuid, admin_id: Uuid) -> Result { + info!("Fetching event"); + let event = sqlx::query_as::<_, EventWithSeats>( + r#" + SELECT + events.*, + json_agg( + json_build_object( + 'id', seat_types.id, + 'name', seat_types.name, + 'description', seat_types.description, + 'price', seat_types.price, + 'capacity', seat_types.capacity + ) + ) FILTER (WHERE seat_types.id IS NOT NULL) AS seat_types + FROM events + LEFT JOIN seat_types ON events.id = seat_types.event_id + WHERE events.id = $1 AND events.admin_id = $2 + GROUP BY events.id + "#, + ) + .bind(event_id) + .bind(admin_id) + .fetch_one(&self.client) + .await?; + + info!("Event fetched successfully"); + Ok(event) + } + + pub async fn update_seats( + &self, + event_id: Uuid, + admin_id: Uuid, + seats: Vec, + ) -> Result<(), DBError> { + info!("Updating seats for event"); + + let mut tx = self.client.begin().await?; + + let event = sqlx::query!( + r#" + SELECT id, start_time + FROM events + WHERE id = $1 AND admin_id = $2 + "#, + event_id, + admin_id + ) + .fetch_optional(&mut *tx) + .await?; + + let event = event.ok_or_else(|| Error::RowNotFound)?; + + let current_time = OffsetDateTime::now_utc(); + + // Check if the event has already started + if event.start_time > current_time { + return Err(DBError::InvalidInput( + "Event has already started".to_string(), + )); + } + + let current_seats = sqlx::query!( + r#" + SELECT id, name, description, price, capacity + FROM seat_types + WHERE event_id = $1 + "#, + event_id + ) + .fetch_all(&mut *tx) + .await?; + + let (new_seats, updated_seats): (Vec<_>, Vec<_>) = + seats.into_iter().partition(|seat| seat.id.is_none()); + + let deleted_seats: Vec = current_seats + .iter() + .filter(|current_seat| { + !updated_seats + .iter() + .any(|seat| seat.id.as_ref() == Some(¤t_seat.id)) + }) + .map(|seat| seat.id) + .collect(); + + // Perform the database operations in a transaction + if !deleted_seats.is_empty() { + sqlx::query!( + r#" + DELETE FROM seat_types + WHERE id = ANY($1) + "#, + &deleted_seats + ) + .execute(&mut *tx) + .await?; + } + + for seat in new_seats { + sqlx::query!( + r#" + INSERT INTO seat_types (id, name, description, price, capacity, event_id) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + Uuid::new_v4(), + seat.name, + seat.description, + seat.price, + seat.capacity, + event_id + ) + .execute(&mut *tx) + .await?; + } + + for seat in updated_seats { + sqlx::query!( + r#" + UPDATE seat_types + SET name = $1, description = $2, price = $3, capacity = $4 + WHERE id = $5 + "#, + seat.name, + seat.description, + seat.price, + seat.capacity, + seat.id.unwrap() + ) + .execute(&mut *tx) + .await?; + } + + // Commit the transaction + tx.commit().await?; + + Ok(()) + } +} diff --git a/latent-backend/db/src/lib.rs b/latent-backend/db/src/lib.rs index 870aee5..ca39170 100644 --- a/latent-backend/db/src/lib.rs +++ b/latent-backend/db/src/lib.rs @@ -1,9 +1,18 @@ +use log::{error, info}; use sqlx::postgres::PgPool; -use log::{info, error}; +mod admin; mod config; +mod event; +mod location; mod user; +#[cfg(debug_assertions)] +pub mod test; + +pub use admin::AdminType; +pub use event::{CreateEventInput, SeatTypeInput, UpdateEventInput, SeatUpdateInput, DBError}; +pub use location::Location; pub use user::User; pub struct Db { @@ -19,7 +28,7 @@ impl Db { pub async fn init(&self) -> Result<(), sqlx::Error> { info!("Running database migrations..."); - + // First verify connection match sqlx::query("SELECT 1").execute(&self.client).await { Ok(_) => info!("Database connection successful"), @@ -34,7 +43,7 @@ impl Db { Ok(_) => { info!("Database migrations completed successfully"); Ok(()) - }, + } Err(e) => { error!("Migration failed: {}", e); Err(e.into()) @@ -42,4 +51,3 @@ impl Db { } } } - diff --git a/latent-backend/db/src/location.rs b/latent-backend/db/src/location.rs new file mode 100644 index 0000000..9e4d0de --- /dev/null +++ b/latent-backend/db/src/location.rs @@ -0,0 +1,49 @@ +use crate::Db; +use sqlx::Error; +use sqlx::FromRow; +use uuid::Uuid; +use serde::{Deserialize, Serialize}; +use log::info; + +#[derive(FromRow, Serialize, Deserialize)] +pub struct Location { + pub id: Uuid, + pub name: String, + pub description: String, + pub image_url: String, +} + +impl Db { + + pub async fn get_location(&self) -> Result, Error> { + info!("Fetching location"); + + let locations = sqlx::query_as::<_, Location>("SELECT * FROM locations") + .fetch_all(&self.client) + .await?; + + info!("Location found"); + Ok(locations) + } + + pub async fn create_location(&self, name: String, description: String, image_url: String) -> Result { + info!("Creating new location"); + + let location = sqlx::query_as::<_, Location>( + r#" + INSERT INTO locations (id, name, description, image_url) + VALUES ($1, $2, $3, $4) + RETURNING * + "# + ) + .bind(Uuid::new_v4()) + .bind(name) + .bind(description) + .bind(image_url) + .fetch_one(&self.client) + .await?; + + info!("Location created/updated successfully"); + Ok(location) + } +} diff --git a/latent-backend/db/src/test.rs b/latent-backend/db/src/test.rs new file mode 100644 index 0000000..025ac3f --- /dev/null +++ b/latent-backend/db/src/test.rs @@ -0,0 +1,52 @@ +use crate::{admin::{Admin, AdminType}, Db, User}; +use sqlx::Error; +use uuid::Uuid; +use log::info; + +impl Db { + pub async fn create_test_admin(&self, phone_number: String, name: String, admin_type: AdminType) -> Result { + info!("Creating test admin with number: {} and name: {}", phone_number, name); + + let admin = sqlx::query_as::<_, Admin>( + r#" + INSERT INTO admins (id, number, name, verified, type) + VALUES ($1, $2, $3, false, $4) + ON CONFLICT (number) DO UPDATE + SET number = EXCLUDED.number + RETURNING * + "# + ) + .bind(Uuid::new_v4()) + .bind(phone_number) + .bind(name) + .bind(admin_type) + .fetch_one(&self.client) + .await?; + + info!("Test admin created/updated successfully with id: {}", admin.id); + Ok(admin) + } + + pub async fn create_test_user(&self, phone_number: String, name: String) -> Result { + info!("Creating test user with number: {} and name: {}", phone_number, name); + + let user = sqlx::query_as::<_, User>( + r#" + INSERT INTO users (id, number, name, verified) + VALUES ($1, $2, $3, false) + ON CONFLICT (number) DO UPDATE + SET number = EXCLUDED.number + RETURNING * + "# + ) + .bind(Uuid::new_v4()) + .bind(phone_number) + .bind(name) + .fetch_one(&self.client) + .await?; + + info!("Test User created/updated successfully with id: {}", user.id); + Ok(user) + } +} +