From 52d27829aae92fa0a307b2d17755bf77ff480cb0 Mon Sep 17 00:00:00 2001 From: Samuel Shuert Date: Sun, 28 Jun 2026 21:45:53 +0000 Subject: [PATCH] feat(backend): add batch --- backend/src/main.rs | 2 + backend/src/models/batch.rs | 87 ++++++++++++++ backend/src/models/mod.rs | 1 + backend/src/routes/batch.rs | 200 +++++++++++++++++++++++++++++++++ backend/src/routes/mod.rs | 1 + backend/src/routes/template.rs | 1 - 6 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 backend/src/models/batch.rs create mode 100644 backend/src/routes/batch.rs diff --git a/backend/src/main.rs b/backend/src/main.rs index a29afba..48761de 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -59,6 +59,8 @@ async fn main() -> std::io::Result<()> { }) .service(actix_web::web::scope("_").service(routes::system::health)) .service(routes::user::service().wrap(RequireAuth)) + .service(routes::batch::service().wrap(RequireAuth)) + .service(routes::customer::service().wrap(RequireAuth)) .service( actix_web::web::scope("/auth") .service(routes::auth::login) diff --git a/backend/src/models/batch.rs b/backend/src/models/batch.rs new file mode 100644 index 0000000..4e88368 --- /dev/null +++ b/backend/src/models/batch.rs @@ -0,0 +1,87 @@ +use crate::models::user::Permissions; + +use super::{ApplyOption, CrudBackend, Update}; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::prelude::FromRow; +use uuid::Uuid; + +#[derive(Debug, Deserialize, Serialize, Clone, FromRow)] +pub struct Batch { + pub id: Uuid, + pub created_at: DateTime, + pub flavor: Uuid, + pub lot_number: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct BatchUpdate { + created_at: Option>, + flavor: Option, + lot_number: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct BatchCreate { + flavor: Uuid, + lot_number: String, +} + +impl Update for BatchUpdate { + fn apply(self, other: &mut Batch) -> bool { + let Self { + flavor, + created_at, + lot_number, + } = self; + flavor.apply_to(&mut other.flavor) + | created_at.apply_to(&mut other.created_at) + | lot_number.apply_to(&mut other.lot_number) + } +} + +impl CrudBackend for Batch { + type Identifier = Uuid; + + type Create = BatchCreate; + type Update = BatchUpdate; + + const TABLE_NAME: &'static str = "batches"; + const PERM_READ: Permissions = Permissions::BatchRead; + const PERM_CREATE: Permissions = Permissions::BatchCreate; + const PERM_UPDATE: Permissions = Permissions::BatchUpdate; + const PERM_DELETE: Permissions = Permissions::BatchDelete; + + fn id(&self) -> Self::Identifier { + self.id + } + + async fn create_inner<'a>( + conn: impl sqlx::prelude::Executor<'a, Database = sqlx::Postgres>, + create: Self::Create, + ) -> Result { + todo!() + } + + async fn read_inner<'a>( + conn: impl sqlx::prelude::Executor<'a, Database = sqlx::Postgres>, + id: Self::Identifier, + ) -> Result { + todo!() + } + + async fn update_inner<'a>( + &mut self, + conn: impl sqlx::prelude::Executor<'a, Database = sqlx::Postgres>, + ) -> Result<()> { + todo!() + } + + async fn delete_inner<'a>( + &self, + conn: impl sqlx::prelude::Executor<'a, Database = sqlx::Postgres>, + ) -> Result<()> { + todo!() + } +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 9472182..4265f08 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -6,6 +6,7 @@ use uuid::Uuid; use crate::models::user::Permissions; +pub mod batch; pub mod customer; pub mod flavor; pub mod ingredient; diff --git a/backend/src/routes/batch.rs b/backend/src/routes/batch.rs new file mode 100644 index 0000000..c58ddec --- /dev/null +++ b/backend/src/routes/batch.rs @@ -0,0 +1,200 @@ +use crate::models::{ + Crud, CrudBackend, + batch::{Batch, BatchCreate, BatchUpdate}, + user::User, +}; +use actix_web::{ + HttpResponse, Responder, Scope, + web::{Data, Json, Path, Query}, +}; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; + +type Main = Batch; +type Create = BatchCreate; +type Update = BatchUpdate; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy)] +enum SortOrder { + Asc, + Desc, +} + +#[derive(Debug, Clone, Deserialize)] +struct QueryParams { + pub limit: Option, + pub offset: Option, + pub sort_order: Option, + pub before: Option>, + pub after: Option>, +} + +#[actix_web::post("")] +async fn post(pool: Data, body: Json, user: User) -> impl Responder { + if !user.permissions.contains(Main::PERM_CREATE) { + return HttpResponse::Forbidden().body(format!( + "You are not authorized to create {}", + Main::TABLE_NAME + )); + } + match Main::create(pool.get_ref(), body.into_inner()).await { + Ok(batch) => HttpResponse::Created().json(batch), + Err(err) => { + tracing::error!("{err:?}"); + HttpResponse::InternalServerError().finish() + } + } +} + +#[actix_web::get("")] +async fn get_all(pool: Data, options: Query, user: User) -> impl Responder { + if !user.permissions.contains(Main::PERM_READ) { + return HttpResponse::Forbidden().body(format!( + "You are not authorized to get {}", + Main::TABLE_NAME + )); + } + + let limit = options.limit.unwrap_or(100) as i64; + let offset = options.offset.unwrap_or(0) as i64; + let sort_asc = options.sort_order.unwrap_or(SortOrder::Desc) == SortOrder::Asc; + + let mut query = sqlx::QueryBuilder::new(format!("SELECT * FROM {}", Main::TABLE_NAME)); + query.push(" WHERE 1=1"); + + if let Some(after) = options.after { + query.push(" AND created_at > "); + query.push_bind(after); // safe bind, no injection risk + } + if let Some(before) = options.before { + query.push(" AND created_at < "); + query.push_bind(before); + } + + query.push(" ORDER BY created_at "); + if sort_asc { + query.push("ASC"); + } else { + query.push("DESC"); + } + + query.push(" LIMIT "); + query.push_bind(limit); + query.push(" OFFSET "); + query.push_bind(offset); + + match query + .build_query_as::
() + .fetch_all(pool.get_ref()) + .await + { + Ok(batches) => HttpResponse::Ok().json(batches), + Err(err) => { + tracing::error!("{err:?}"); + HttpResponse::InternalServerError().finish() + } + } +} + +#[actix_web::get("/{id}")] +async fn get(pool: Data, id: Path, user: User) -> impl Responder { + if !user.permissions.contains(Main::PERM_READ) { + return HttpResponse::Forbidden().body(format!( + "You are not authorized to get {}", + Main::TABLE_NAME + )); + } + + match Main::read(pool.get_ref(), id.into_inner()).await { + Ok(resource) => HttpResponse::Ok().json(resource), + Err(err) => { + if let Some(sqlx_err) = err.downcast_ref::() { + match sqlx_err { + sqlx::Error::RowNotFound => return HttpResponse::NotFound().finish(), + _ => (), + } + } + tracing::error!("{err:?}"); + HttpResponse::InternalServerError().finish() + } + } +} + +#[actix_web::patch("/{id}")] +async fn patch( + pool: Data, + id: Path, + body: Json, + user: User, +) -> impl Responder { + if !user.permissions.contains(Main::PERM_UPDATE) { + return HttpResponse::Forbidden().body(format!( + "You are not authorized to update {}", + Main::TABLE_NAME + )); + } + + let mut resource = match Main::read(pool.get_ref(), id.into_inner()).await { + Ok(resource) => resource, + Err(err) => { + if let Some(sqlx_err) = err.downcast_ref::() { + match sqlx_err { + sqlx::Error::RowNotFound => return HttpResponse::NotFound().finish(), + _ => (), + } + } + tracing::error!("{err:?}"); + return HttpResponse::InternalServerError().finish(); + } + }; + + match resource.update(pool.get_ref(), body.into_inner()).await { + Ok(()) => HttpResponse::Ok().json(resource), + Err(err) => { + tracing::error!("{err:?}"); + HttpResponse::InternalServerError().finish() + } + } +} + +#[actix_web::delete("/{id}")] +async fn delete(pool: Data, id: Path, user: User) -> impl Responder { + if !user.permissions.contains(Main::PERM_DELETE) { + return HttpResponse::Forbidden().body(format!( + "You are not authorized to delete {}", + Main::TABLE_NAME + )); + } + + let resource = match Main::read(pool.get_ref(), id.into_inner()).await { + Ok(resource) => resource, + Err(err) => { + if let Some(sqlx_err) = err.downcast_ref::() { + match sqlx_err { + sqlx::Error::RowNotFound => return HttpResponse::NotFound().finish(), + _ => (), + } + } + tracing::error!("{err:?}"); + return HttpResponse::InternalServerError().finish(); + } + }; + + match resource.delete(pool.get_ref()).await { + Ok(()) => HttpResponse::NoContent().finish(), + Err(err) => { + tracing::error!("{err:?}"); + HttpResponse::InternalServerError().finish() + } + } +} + +pub fn service() -> Scope { + actix_web::web::scope(&format!("/{}", Main::TABLE_NAME)) + .service(post) + .service(get) + .service(get_all) + .service(patch) + .service(delete) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 60dac2d..8fab0f2 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1,3 +1,4 @@ pub mod auth; +pub mod batch; pub mod system; pub mod user; diff --git a/backend/src/routes/template.rs b/backend/src/routes/template.rs index 6d44b03..abc7798 100644 --- a/backend/src/routes/template.rs +++ b/backend/src/routes/template.rs @@ -9,7 +9,6 @@ use uuid::Uuid; type Main = todo!(); type Create = todo!(); -type Read = todo!(); type Update = todo!(); #[derive(Debug, Clone, Deserialize)] -- 2.51.2