COMP5022 Innovative Product Development - Individual Journal for """Agile""" development

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:

Annotated brief

Sprint 1 (2022-11-10Z/24)

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.

Sprint 2 (2022-11-24Z/12-08)

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:

API design (question mark in key name: field may not be present; question mark in type: field may be null):

I would later add:

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)),
    })
}

Sprint 3 (2022-02-02Z/16)

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",
                    }),
                ))
            },
        }
    }
}

Sprint 4 (2022-02-16Z/03-02)

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,
    })
}

Sprint 5 (2022-03-02Z/16)

This cycle we went on code freeze, so no code changes were made.

The planning document can be found here.

Sprint 6 (2022-03-16Z/30)

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 (2022-04-20Z/05-04)

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.