First off, my thoughts on the scrum structure we've been mostly forced into, in the form of annotations on the brief for this document:
I spent the first sprint primarily by setting up infrastructure for the project. I created both the Flutter project template, as well as the Rust project template, installed some additional dependencies into the Rust project (Rocket, Diesel, serde_json
, argon2
, and rocket-jwt
). Furthermore, I wrote the project style guides and created a few scripts to ease development.
No user story was necessary for this, as it was an internal process
Additionally, under the story 'As a stakeholder, I want to see the layout of the site', I created a demo with Figma.
The plan for these weeks can be found here.
The Figma model can be found here and its success criterion was to effectively demonstrate the intended layout of the site.
The second cycle was spent on the user story 'As a user I want to be able to log into the system so that I can see my list of groups'. Under this guise I would design the back-end API for interacting with the database, and then implement it. I would continue to expand the API over the coming weeks.
This cycle I would work specifically on the login endpoint, but I did design a moderate portion of the rest of the API and create a utility script.
This cycle's plan can be found here.
I originally posted the API design on Discord, which I will now reproduce here:
Backend code for:
- find user,
- check password,
- confirm login,
- insert new users,
- assign token (stay logged in),
- refresh tokens (token logins only),
- send information for user (user info, active group/s).
API design (question mark in key name: field may not be present; question mark in type: field may be null):
- POST
/login
- Takes JSON
{"username": string, "password": string}
(yes plaintext but we've got HTTPS)- Returns JSON
{"token": string}
or error JSON{"error_type": ("username" | "password")}
- POST
/signup
- Takes JSON
{"username": string, "password": string, "email": string, "mobile": string}
- Returns JSON
{"token": string}
or error JSON{"error_type": ("username" | "mobile" | "email")}
(for username taken, mobile in use, or email in use, respectively)- GET
/groups/me
- Header must include the
Authorization
header, with a value of"Bearer [token]"
- Returns JSON
{"groups": ({"name": string, "currency": string, "parts_per_unit": number(int), "id": number(int), "allow_admin_controls": bool})[], "default": number(int)?}
or error JSON{"error_type": "expired"}
- GET
/groups/[id]/info
- Header must include the
Authorization
header, with a value of"Bearer [token]"
- Returns JSON
{"name": string, "currency": string, "parts_per_unit": number(int), "id": number(int), "allow_admin_controls": bool, "unread_transactions"?: number(int)}
or error JSON{"error_type": ("expired" | "not_member")}
(for expired token, or the user does not belong to that group, respectively)- GET
/groups/[id]/members
- Header must include the
Authorization
header, with a value of"Bearer [token]"
- Returns JSON
({"nickname": string, "id": number(int), "is_me": bool, "is_admin": bool, "balance": number(int)})[]
- GET
/groups/[id]/objects
- Header must include the
Authorization
header, with a value of"Bearer [token]"
- Returns JSON
({"Item": {"id": number(int), "name": string, "qty": number(int), "price": number(int)}} | {"Utility": {"id": number(int), "name": string}} | {"Resources": {"id": number(int), "name": string}})[]
or error JSON{"error_type": ("expired" | "not_member")}
- GET
/groups/[id]/objects/[id]
- Header must include the
Authorization
header, with a value of"Bearer [token]"
- Returns blob (image) or error JSON
{"error_type": ("expired" | "not_member")}
- PATCH
/groups/[id]/settings
- Header must include the
Authorization
header, with a value of"Bearer [token]"
- Takes JSON
{"currency"?: string, "parts_per_unit"?: number(int), "name"?: string}
- Returns JSON
{"name": string, "parts_per_unit": number(int), "currency": string}
or error JSON{"error_type": ("expired" | "not_member" | "not_admin")}
(for expired token, the user does not belong to that group, or the user is not an admin of that group, respectively)
I would later add:
- POST
/token
- Header must include the Authorization header, with a value of
"Bearer [old token]"
- Returns JSON
{"token": string}
(issues a new token)
The following details blocks contain the (relevant) code from this work:
http
#!/bin/bash
method=$1
shift
# happy method ^^
curl -X ${method^^} -H "Content-Type: application/json" "$@"
src/lib.rs
#[cfg(not(target_os = "windows"))]
static RSA_SECRET_BYTES: &[u8] = include_bytes!(concat!(env!("HOME"), "/.ssh/id_rsa"));
#[cfg(target_os = "windows")]
static RSA_SECRET_BYTES: &[u8] =
include_bytes!(concat!(env!("HOMEDRIVE"), env!("HOMEPATH"), "/.ssh/id_rsa"));
#[cfg(not(target_os = "windows"))]
static SECRET_KEY: &str = include_str!(concat!(env!("HOME"), "/.ssh/id_rsa"));
#[cfg(target_os = "windows")]
static SECRET_KEY: &str =
include_str!(concat!(env!("HOMEDRIVE"), env!("HOMEPATH"), "/.ssh/id_rsa"));
#[jwt(SECRET_KEY)]
pub struct UserClaim(pub i32);
/// Insert a user into the database
///
/// The user's password is hashed from within this function.
///
/// # Errors
///
/// If the database instruction fails.
pub fn create_user(
conn: &mut PgConnection,
username: &str,
password: &str,
email: &str,
mobile: &str,
) -> QueryResult<usize> {
use rand_chacha::ChaChaRng;
use rand_core::SeedableRng;
use crate::schema::users::dsl::users;
let mut rng = ChaChaRng::from_entropy();
let salt = SaltString::generate(&mut rng);
let hash = Argon2::new_with_secret(
RSA_SECRET_BYTES,
Algorithm::Argon2id,
Version::V0x13,
Params::default(),
)
.expect("Should be valid based on compile-time settings")
.hash_password(password.as_bytes(), &salt)
.expect("Hashing a password should not fail");
let pwd_hash = format!(
"{}${}",
salt.as_str(),
hash.hash
.expect("Should have a password hash after hashing the password")
.as_bytes()
.encode_hex::<String>()
);
diesel::insert_into(users)
.values(NewUser {
username,
pwd_hash: &pwd_hash,
email,
mobile,
})
.execute(conn)
}
src/routes.rs
#[must_use]
pub enum JsonResult<T: Serialize, E: Serialize> {
Success(T),
Error(E),
InternalError,
}
impl<'r, 'o: 'r, T: Serialize, E: Serialize> Responder<'r, 'o> for JsonResult<T, E> {
fn respond_to(
self,
request: &'r rocket::Request<'_>,
) -> rocket::response::Result<'o> {
match self {
JsonResult::Success(data) => Json(data).respond_to(request),
JsonResult::Error(err) => {
Custom(Status::Unauthorized, Json(err)).respond_to(request)
},
JsonResult::InternalError => Err(Status::ServiceUnavailable),
}
}
}
#[derive(Debug, Serialize)]
pub struct GenericError {
pub error_type: &'static str,
}
#[derive(Deserialize)]
pub struct LoginData {
pub username: String,
pub password: String,
}
#[derive(Serialize)]
pub struct LoginResult {
pub token: String,
}
#[post("/login", format = "json", data = "<data>")]
pub async fn login(
db: Db,
data: Json<LoginData>,
) -> JsonResult<LoginResult, GenericError> {
let Json(LoginData {
username: user_username,
password,
}) = data;
let Ok(users): QueryResult<Vec<User>> = db
.run(move |conn| {
use crate::users::dsl::{username, users};
users
.filter(username.eq(user_username))
.limit(1)
.load::<User>(conn)
})
.await
else {
return JsonResult::InternalError;
};
let Some(user) = users.get(0)
else {
return JsonResult::Error(GenericError {
error_type: "username",
});
};
let Some((user_password_salt, user_password_hash)) = user.pwd_hash.split_once('$')
else {
return JsonResult::InternalError;
};
let Ok(hash_from_password) = Argon2::new_with_secret(
RSA_SECRET_BYTES,
argon2::Algorithm::Argon2id,
argon2::Version::V0x13,
argon2::Params::default(),
)
.expect("Should be valid based on compile-time settings")
.hash_password(password.as_bytes(), user_password_salt)
else {
return JsonResult::InternalError;
};
if hash_from_password
.hash
.expect("Should have a password hash after hashing the password")
.as_bytes()
.encode_hex::<String>()
!= user_password_hash
{
return JsonResult::Error(GenericError {
error_type: "password",
});
}
JsonResult::Success(LoginResult {
token: UserClaim::sign(UserClaim(user.id)),
})
}
Under the story of 'As a supplier for the house, I want to be able to add items to the system', my task was to work on the backend API. I also improved the style guide to provide guidance on a construct which is not yet formatted, and cleaned up some of the backend code (these changes have been included where the code was originally included)
The planning document can be found here.
A summary of the code changes follow:
src/routes.rs
#[derive(Clone, Deserialize)]
pub struct SignUpData {
pub username: String,
pub password: String,
pub email: String,
pub mobile: String,
}
#[derive(Serialize)]
pub struct SignUpResult {
pub token: String,
}
#[post("/signup", format = "json", data = "<data>")]
pub async fn signup(
db: Db,
data: Json<SignUpData>,
) -> JsonResult<SignUpResult, GenericError> {
let Json(data) = data;
let Ok(clash_type): QueryResult<Option<&str>> = db
.run({
let SignUpData {
username: user_username,
password: _,
email: user_email,
mobile: user_mobile,
} = data.clone();
move |conn| {
use crate::users::dsl::{email, mobile, username, users};
let user_clashes = users
.filter(username.eq(&user_username))
.limit(1)
.load::<User>(conn)?;
if !user_clashes.is_empty() {
return Ok(Some("username"));
}
let email_clashes = users
.filter(email.eq(&user_email))
.limit(1)
.load::<User>(conn)?;
if !email_clashes.is_empty() {
return Ok(Some("email"));
}
let mobile_clashes = users
.filter(mobile.eq(&user_mobile))
.limit(1)
.load::<User>(conn)?;
if !mobile_clashes.is_empty() {
return Ok(Some("mobile"));
}
Ok(None)
}
})
.await
else {
return JsonResult::InternalError;
};
if let Some(clash_type) = clash_type {
return JsonResult::Error(GenericError {
error_type: clash_type,
});
}
let SignUpData {
username: user_username,
password,
email: user_email,
mobile: user_mobile,
} = data;
let salt = SaltString::generate(&mut rand::thread_rng());
let Ok(hash_from_password) = Argon2::new_with_secret(
RSA_SECRET_BYTES,
argon2::Algorithm::Argon2id,
argon2::Version::V0x13,
argon2::Params::default(),
)
.expect("Should be valid based on compile-time settings")
.hash_password(password.as_bytes(), &salt)
else {
return JsonResult::InternalError;
};
let salt_str = salt.as_str();
let hashed_password = format!(
"{salt_str}${}",
hash_from_password
.hash
.expect("Should have a password hash after hashing the password")
.as_bytes()
.encode_hex::<String>(),
);
let Ok(user) = db
.run(move |conn| {
use crate::users::dsl::{email, mobile, username, users, pwd_hash};
diesel::insert_into(users)
.values((
username.eq(&user_username),
email.eq(&user_email),
mobile.eq(&user_mobile),
pwd_hash.eq(&hashed_password),
))
.get_result::<User>(conn)
})
.await
else {
return JsonResult::InternalError;
};
JsonResult::Success(SignUpResult {
token: UserClaim::sign(UserClaim(user.id)),
})
}
#[derive(Serialize)]
pub struct RefreshResult {
pub token: String,
}
#[post("/token-refresh")]
pub fn token_refresh(user: UserGuard) -> JsonResult<RefreshResult, GenericError> {
JsonResult::Success(RefreshResult {
token: UserClaim::sign(user.0),
})
}
src/guards.rs
pub struct UserGuard(pub UserClaim);
#[async_trait]
impl<'r> FromRequest<'r> for UserGuard {
type Error = Json<GenericError>;
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let Some(token) = req.headers().get_one("Authorization")
else {
return Outcome::Failure((
Status::BadRequest,
Json(GenericError {
error_type: "missing",
}),
));
};
let Some(token) = token.strip_prefix("Bearer ")
else {
return Outcome::Failure((
Status::BadRequest,
Json(GenericError {
error_type: "malformed",
}),
));
};
let token = UserClaim::decode(token.to_string());
match token {
Ok(token) => Outcome::Success(UserGuard(token.user)),
Err(_) => {
Outcome::Failure((
Status::Unauthorized,
Json(GenericError {
error_type: "expired",
}),
))
},
}
}
}
This cycle I would create the code to calculate a user's balance, and to query the user's groups.
The planning document can be found here.
No specific actionable tasks were created for this cycle, so I continued working on the backend code.
A summary of the code changes follow:
src/db.rs
/// Calculate the balance for a given user in a given group.
///
/// # Errors
///
/// This function will return an error if the database connection errors.
pub fn calculate_balance_for(
conn: &mut PgConnection,
group: i32,
user: i32,
) -> QueryResult<i32> {
use crate::schema::items::dsl::items;
use crate::schema::resources::dsl::{self as resources_dsl, resources};
use crate::schema::transactions::dsl::{group_id, transactions, user_id};
use crate::schema::utilities::dsl::{self as utilities_dsl, utilities};
let raw_transactions: Vec<Transaction> = transactions
.left_join(items)
.left_join(utilities)
.left_join(resources)
.filter(group_id.eq(group))
.filter(user_id.eq(user))
.load::<Transaction>(conn)?;
let utility_ids = raw_transactions
.iter()
.filter_map(|t| {
if let Object::Utility(u) = &t.object {
Some(u.id)
} else {
None
}
})
.collect::<HashSet<i32>>();
let utility_uses = utilities
.filter(utilities_dsl::id.eq_any(utility_ids))
.group_by(utilities_dsl::id)
.select((utilities_dsl::id, count_star()))
.load::<(i32, i64)>(conn)?
.into_iter()
.map(|(id, count)| {
(
id,
count
.try_into()
.expect("Number of rows should always fit in an i32"),
)
})
.collect::<HashMap<i32, i32>>();
let resource_ids = raw_transactions
.iter()
.filter_map(|t| {
if let Object::Resource(r) = &t.object {
Some(r.id)
} else {
None
}
})
.collect::<HashSet<i32>>();
let resource_uses = resources
.filter(resources_dsl::id.eq_any(resource_ids))
.group_by(resources_dsl::id)
.select((resources_dsl::id, count_star()))
.load::<(i32, i64)>(conn)?
.into_iter()
.map(|(id, count)| {
(
id,
count
.try_into()
.expect("Number of rows should always fit in an i32"),
)
})
.collect::<HashMap<i32, i32>>();
let mut balance = 0;
for transaction in raw_transactions {
match transaction.object {
Object::Item(item) => {
if transaction.transaction_type == TransactionType::Buy {
balance -= item.price;
} else {
balance += item.price;
}
},
Object::Utility(utility) => {
if transaction.transaction_type == TransactionType::Buy {
balance -= utility.price / utility_uses[&utility.id];
} else {
balance += utility.price;
}
},
Object::Resource(resource) => {
if transaction.transaction_type == TransactionType::Buy {
balance -= resource.price / resource_uses[&resource.id];
} else {
balance += resource.price;
}
},
}
}
Ok(balance)
}
src/models.rs
type TransactionRawRow = (
RawTransaction,
Option<Item>,
Option<Utility>,
Option<Resource>,
);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Transaction {
pub id: i32,
pub user_id: i32,
pub group_id: i32,
pub transaction_type: TransactionType,
pub object: Object,
pub approved: bool,
}
impl<ST, DB: Backend> Queryable<ST, DB> for Transaction
where
TransactionRawRow: Queryable<ST, DB>,
{
type Row = <TransactionRawRow as Queryable<ST, DB>>::Row;
fn build(raw_row: Self::Row) -> diesel::deserialize::Result<Self> {
let (raw, item, utility, resource) = TransactionRawRow::build(raw_row)?;
Ok(Self {
id: raw.id,
user_id: raw.user_id,
group_id: raw.group_id,
transaction_type: if raw.was_buy {
TransactionType::Buy
} else {
TransactionType::Sell
},
object: match (item, resource, utility) {
(Some(item), None, None) => Object::Item(item),
(None, Some(resource), None) => Object::Resource(resource),
(None, None, Some(utility)) => Object::Utility(utility),
_ => Err("Invalid transaction row; must contain exactly one object")?,
},
approved: raw.approved,
})
}
}
src/routes.rs
#[derive(Serialize)]
pub struct GroupResult {
pub id: i32,
pub name: String,
pub currency: String,
pub parts_per_unit: i32,
pub balance: i32,
pub allow_admin_controls: bool,
}
#[derive(Serialize)]
pub struct GroupsResult {
pub groups: Vec<GroupResult>,
pub default: Option<i32>,
}
#[get("/groups/me")]
pub async fn groups_me(
db: Db,
user: UserGuard,
) -> JsonResult<GroupsResult, GenericError> {
let Ok(user_model): QueryResult<User> = db.run(move |conn| {
use crate::users::dsl::{id, users};
users
.filter(id.eq(user.0 .0))
.first::<User>(conn)
}).await
else {
return JsonResult::InternalError;
};
let default = user_model.default_group;
let Ok(groups): QueryResult<Vec<(UserGroup, Group)>> = db.run(move |conn| {
use crate::user_groups::dsl::{user_groups, user_id};
use crate::groups::dsl::groups;
user_groups
.inner_join(groups)
.filter(user_id.eq(user.0 .0))
.load::<(UserGroup, Group)>(conn)
}).await
else {
return JsonResult::InternalError;
};
let user_id = user.0 .0;
JsonResult::Success(GroupsResult {
groups: match db
.run({
move |conn| -> QueryResult<Vec<GroupResult>> {
let mut result = Vec::with_capacity(groups.len());
for (user_group, group) in groups {
result.push(GroupResult {
id: group.id,
name: group.name,
currency: group.currency,
parts_per_unit: group.parts_per_unit,
balance: calculate_balance_for(conn, user_id, group.id)?,
allow_admin_controls: user_group.is_admin,
});
}
Ok(result)
}
})
.await
{
Ok(val) => val,
Err(_) => return JsonResult::InternalError,
},
default,
})
}
This cycle we went on code freeze, so no code changes were made.
The planning document can be found here.
This cycle I would create the route code for buying an object and creating an object under the story 'As a user I would like to be able to add a product to the inventory'. Additionally, I modified the calculate_balance_for
function to use the new, better, database schema and moved the database helper functions into src/db.rs
.
The planning document can be found here.
The code changes follow:
src/db.rs
/// Create an item in the database. Any required permissions check should happen
/// first.
///
/// # Errors
///
/// If the database instruction fails.
#[allow(clippy::cast_possible_wrap)]
pub fn create_item(
conn: &mut PgConnection,
group: i32,
name: String,
cost: u32,
qty: u32,
) -> QueryResult<Item> {
use crate::objects::dsl::{self, objects};
let obj = diesel::insert_into(objects)
.values((
dsl::group_id.eq(group),
dsl::name.eq(name),
dsl::price.eq(cost as i32),
dsl::qty.eq(qty as i32),
dsl::type_.eq(ObjectType::Item),
))
.get_result::<Object>(conn)?;
if let Object::Item(item) = obj {
Ok(item)
} else {
unreachable!("Bad database state")
}
}
/// Create a resource in the database. Any required permissions check should
/// happen first.
///
/// # Errors
///
/// If the database instruction fails.
#[allow(clippy::cast_possible_wrap)]
pub fn create_resource(
conn: &mut PgConnection,
group: i32,
name: String,
cost: u32,
) -> QueryResult<Resource> {
use crate::objects::dsl::{self, objects};
let obj = diesel::insert_into(objects)
.values((
dsl::group_id.eq(group),
dsl::name.eq(name),
dsl::price.eq(cost as i32),
dsl::exhausted.eq(false),
dsl::type_.eq(ObjectType::Resource),
))
.get_result::<Object>(conn)?;
if let Object::Resource(res) = obj {
Ok(res)
} else {
unreachable!("Bad database state")
}
}
/// Create a utility in the database. Any required permissions check should
/// happen first.
///
/// # Errors
///
/// If the database instruction fails.
#[allow(clippy::cast_possible_wrap)]
pub fn create_utility(
conn: &mut PgConnection,
group: i32,
name: String,
cost: u32,
) -> QueryResult<Utility> {
use crate::objects::dsl::{self, objects};
use crate::transactions::dsl::{self as t_dsl, transactions};
let obj = diesel::insert_into(objects)
.values((
dsl::group_id.eq(group),
dsl::name.eq(name),
dsl::price.eq(cost as i32),
dsl::type_.eq(ObjectType::Utility),
))
.get_result::<Object>(conn)?;
diesel::insert_into(transactions)
.values((
t_dsl::group_id.eq(group),
t_dsl::user_id.eq(0),
t_dsl::was_buy.eq(false),
t_dsl::approved.eq(false),
t_dsl::object_id.eq(obj.id()),
))
.execute(conn)?;
if let Object::Utility(util) = obj {
Ok(util)
} else {
unreachable!("Bad database state")
}
}
/// Calculate the balance for a given user in a given group.
///
/// # Errors
///
/// This function will return an error if the database connection errors.
pub fn calculate_balance_for(
conn: &mut PgConnection,
group: i32,
user: i32,
) -> QueryResult<i32> {
use crate::schema::objects::dsl::{id, objects};
use crate::schema::transactions::dsl::{group_id, transactions, user_id};
let transaction_rows: Vec<Transaction> = transactions
.inner_join(objects)
.filter(group_id.eq(group))
// .filter(approved.eq(true))
.filter(user_id.eq(user))
.load::<Transaction>(conn)?;
let util_resource_ids = transaction_rows
.iter()
.filter_map(|t| {
if let Object::Utility(Utility {
id: obj_id, ..
})
| Object::Resource(Resource {
id: obj_id, ..
}) = &t.object
{
Some(*obj_id)
} else {
None
}
})
.collect::<HashSet<i32>>();
let object_uses = objects
.filter(id.eq_any(util_resource_ids))
.group_by(id)
.select((id, count_star()))
.load::<(i32, i64)>(conn)?
.into_iter()
.map(|(obj_id, count)| {
(
obj_id,
count
.try_into()
.expect("Number of rows should always fit in an i64"),
)
})
.collect::<HashMap<i32, i32>>();
let mut balance = 0;
for transaction in transaction_rows {
match transaction.object {
Object::Item(item) => {
if transaction.transaction_type == TransactionType::Buy {
balance -= item.price;
} else {
balance += item.price;
}
},
Object::Utility(utility) => {
if transaction.transaction_type == TransactionType::Buy {
balance -= utility.price / object_uses[&utility.id];
} else {
balance += utility.price;
}
},
Object::Resource(resource) => {
if transaction.transaction_type == TransactionType::Buy {
balance -= resource.price / object_uses[&resource.id];
} else {
balance += resource.price;
}
},
}
}
Ok(balance)
}
src/routes.rs
#[derive(Deserialize)]
pub struct CreateObjectData {
r#type: ObjectType,
name: String,
cost: u32,
qty: Option<u32>,
}
#[derive(Serialize)]
pub struct CreateObjectResult {
id: u32,
}
#[post("/groups/<group_id>/objects/create", format = "json", data = "<data>")]
pub async fn create_object(
db: Db,
group_id: i32,
user: UserGuard,
data: Json<CreateObjectData>,
) -> JsonResult<CreateObjectResult, GenericError> {
let Json(data) = data;
let CreateObjectData {
r#type,
name,
cost,
qty,
} = data;
let _ = user.id; // No group check until groups are implemented
match (r#type, qty) {
(ObjectType::Item, None)
| (ObjectType::Resource | ObjectType::Utility, Some(_)) => {
JsonResult::Error(GenericError {
error_type: "qty",
})
},
(ObjectType::Item, Some(qty)) => {
db.run(move |conn| create_item(conn, group_id, name, cost, qty))
.await
.map_or(JsonResult::InternalError, |item| {
#[allow(clippy::cast_sign_loss)]
JsonResult::Success(CreateObjectResult {
id: item.id as u32,
})
})
},
(ObjectType::Resource, None) => {
db.run(move |conn| create_resource(conn, group_id, name, cost))
.await
.map_or(
JsonResult::InternalError,
#[allow(clippy::cast_sign_loss)]
|res| {
JsonResult::Success(CreateObjectResult {
id: res.id as u32,
})
},
)
},
(ObjectType::Utility, None) => {
db.run(move |conn| create_utility(conn, group_id, name, cost))
.await
.map_or(
JsonResult::InternalError,
#[allow(clippy::cast_sign_loss)]
|util| {
JsonResult::Success(CreateObjectResult {
id: util.id as u32,
})
},
)
},
}
}
#[derive(Deserialize)]
pub struct BuyObjectPayload {
qty: u32,
}
#[derive(Serialize)]
pub struct BuyObjectResult {
balance: i32,
}
#[post(
"/groups/<group>/objects/<object>/buy",
format = "json",
data = "<data>"
)]
pub async fn buy_object(
db: Db,
user: UserGuard,
group: i32,
object: i32,
data: Json<BuyObjectPayload>,
) -> JsonResult<BuyObjectResult, GenericError> {
use crate::objects::dsl::{objects, qty};
let uid = user.id; // No group check until groups are implemented
let Json(data) = data;
let BuyObjectPayload {
qty: requested_qty,
} = data;
let Ok(requested_objects) = db
.run(move |conn| {
objects
.find(object)
.load::<Object>(conn)
})
.await
else {
return JsonResult::InternalError;
};
let Some(requested_object) = requested_objects.into_iter().next()
else {
return JsonResult::Error(GenericError {
error_type: "bad_object",
});
};
if let Object::Item(item) = requested_object {
#[allow(clippy::cast_sign_loss)]
if requested_qty > (item.qty as u32) {
return JsonResult::Error(GenericError {
error_type: "not_enough",
});
}
#[allow(clippy::cast_possible_wrap, reason = "qty is always <= item.qty")]
let new_qty = item.qty - requested_qty as i32;
if db
.run(move |conn| {
diesel::update(objects.find(&item.id))
.set(qty.eq(new_qty))
.get_result::<Object>(conn)
})
.await
.is_err()
{
return JsonResult::InternalError;
}
}
db.run(move |conn| {
use crate::transactions::dsl::{group_id, object_id, transactions, user_id};
for _ in 0..requested_qty {
diesel::insert_into(transactions)
.values((
user_id.eq(uid),
group_id.eq(group),
object_id.eq(object),
was_buy.eq(true),
approved.eq(false),
))
.execute(conn)?;
}
calculate_balance_for(conn, group, user.id)
})
.await
.map_or(
JsonResult::InternalError,
#[allow(clippy::cast_sign_loss)]
|balance| {
JsonResult::Success(BuyObjectResult {
balance,
})
},
)
}
Sprint 7 has been predominantly put on pause due to the fact that this very coursework is due during it. However, Ben has taken on a task and so has created a document for this sprint.