Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ccad752
feat: implement backend route for getting all average users rating pe…
A2H4D Dec 17, 2025
68973c4
fix: remove uneeded file
A2H4D Dec 17, 2025
e697fc9
feat: Update the extraction logic for backend and fronend
A2H4D Dec 18, 2025
e172f3d
feat: adding button for average page from campaign
A2H4D Dec 18, 2025
793b349
feat: implement the front end and backend testing update
A2H4D Dec 18, 2025
2352491
feat: moving the filter the right most table position
A2H4D Dec 19, 2025
ec37efc
feat: fixing the calculation on frontend and update backend extraction
A2H4D Dec 20, 2025
86fcbfe
feat: updating the looks on the front end so that individual review l…
A2H4D Dec 20, 2025
b01005a
fix: recover deleted package-lock.json
A2H4D Dec 20, 2025
b02e284
feat: update to use dic for future easy usage (new features)
A2H4D Dec 20, 2025
6fa8ba1
Delete frontend/env.development
KavikaPalletenne Dec 25, 2025
fc24b4d
fix seeder org admin overwrite
KavikaPalletenne Dec 25, 2025
c114f02
application ratings model fixes
KavikaPalletenne Dec 25, 2025
79be9d2
update seeder ratings to 1-10
KavikaPalletenne Dec 28, 2025
fda180b
new application summary page
KavikaPalletenne Dec 28, 2025
4cdabf7
update dependencies
KavikaPalletenne Dec 28, 2025
2b27e9e
update nextjs
KavikaPalletenne Dec 28, 2025
5bd7f74
new `CampaignOrgMember` authZ guard
KavikaPalletenne Dec 28, 2025
2eb703d
Merge branch 'CHAOS-598-nextjs' into CHAOS-624-applicants_rating
KavikaPalletenne Dec 28, 2025
7314613
fix lucide `BarChart` icon import
KavikaPalletenne Dec 28, 2025
7648d32
rollback `react-resizable-panels`
KavikaPalletenne Dec 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions backend/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2103,6 +2103,34 @@ paths:
"401":
$ref: "#/components/responses/NotLoggedIn"

/campaign/{campaign_id}/avg_ratings:
get:
operationId: getApplicationAvgRatings
parameters:
- name: campaign_id
in: path
description: Campaign ID
required: true
schema:
type: integer
format: int64
description: Returns average ratings grouped by user for the specified application
tags:
- Application
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/UserAvgApplicationRating"
"401":
$ref: "#/components/responses/NotOrganisationAdmin"
"307":
$ref: "#/components/responses/NotLoggedIn"

/offer/{offer_id}:
get:
operationId: getOffer
Expand Down Expand Up @@ -3042,6 +3070,39 @@ components:
items:
$ref: "#/components/schemas/RatingDetails"

UserAvgApplicationRating:
type: object
properties:
application_id:
type: string
description: Application ID (as string to preserve large integer precision)
example: "1541815603606036480"
campaign_role_id:
type: string
description: Role ID within the campaign (as string to preserve large integer precision)
example: "1541815603606036480"
campaign_role_name:
type: string
description: Name of the campaign role
example: "Software Engineer"
user_name:
type: string
description: User's full name
example: "John Doe"
user_email:
type: string
format: email
description: User's email address
example: "john.doe@example.com"
status:
$ref: "#/components/schemas/ApplicationStatus"
avg_rating:
type: number
format: double
nullable: true
description: Average rating for this user's application in the given role (null if no ratings exist)
example: 4.5

RatingUpdate:
type: object
properties:
Expand Down
6 changes: 3 additions & 3 deletions backend/database-seeding/src/seeder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,23 +341,23 @@ pub async fn seed_database(dev_email: String, mut seeder: Seeder) {
.await.expect("Failed seeding Answer 3");

Rating::create(
NewRating { rating: 69, comment: Some("This guy does not know what they are talking about!".to_string()) },
NewRating { rating: 7, comment: Some("This guy does not know what they are talking about!".to_string()) },
application_id_1,
2,
&mut seeder.app_state.snowflake_generator,
&mut tx)
.await.expect("Failed seeding Rating 1");

Rating::create(
NewRating { rating: 100, comment: None },
NewRating { rating: 10, comment: None },
application_id_2,
2,
&mut seeder.app_state.snowflake_generator,
&mut tx)
.await.expect("Failed seeding Rating 2");

Rating::create(
NewRating { rating: 100, comment: Some("My cousin's restaurant could use a janitor".to_string()) },
NewRating { rating: 1, comment: Some("My cousin's restaurant could use a janitor".to_string()) },
application_id_2,
1,
&mut seeder.app_state.snowflake_generator,
Expand Down
28 changes: 27 additions & 1 deletion backend/server/src/handler/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

use crate::models::app::{AppMessage, AppState};
use crate::models::application::{Application, ApplicationRoleUpdate, ApplicationStatus, OpenApplicationByApplicationId};
use crate::models::auth::{ApplicationAdmin, ApplicationOwner, ApplicationOwnerOrReviewer, ApplicationReviewerGivenApplicationId, AuthUser};
use crate::models::auth::{ApplicationAdmin, ApplicationOwner, ApplicationOwnerOrReviewer, ApplicationReviewerGivenApplicationId, AuthUser, CampaignAdmin, CampaignOrgMember};
use crate::models::error::ChaosError;
use crate::models::transaction::DBTransaction;
use axum::extract::{Json, Path, State};
Expand Down Expand Up @@ -335,4 +335,30 @@ impl ApplicationHandler {
transaction.tx.commit().await?;
Ok((StatusCode::OK, Json(ratings)))
}

/// Retrieves the average ratings for all users in an application.
///
/// This handler allows application reviewers to view the average ratings for all users in an application.
///
/// # Arguments
///
/// * `_user` - The authenticated user (must be an application reviewer)
/// * `application_id` - The ID of the application
/// * `transaction` - Database transaction
///
/// # Returns
///
/// * `Result<impl IntoResponse, ChaosError>` - List of average ratings or error
pub async fn get_application_ratings_summary(
_: CampaignOrgMember,
Path(campaign_id): Path<i64>,
mut transaction: DBTransaction<'_>,
) -> Result<impl IntoResponse, ChaosError> {
let avg_applications_ratings =
Application::get_application_ratings_summary(campaign_id, &mut transaction.tx)
.await?;
transaction.tx.commit().await?;

Ok(Json(avg_applications_ratings))
}
}
4 changes: 4 additions & 0 deletions backend/server/src/models/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,10 @@ pub async fn app() -> Result<Router, ChaosError> {
"/api/v1/application/:application_id/ratings",
get(ApplicationHandler::get_ratings),
)
.route(
"/api/v1/campaign/:campaign_id/avg_ratings",
get(ApplicationHandler::get_application_ratings_summary),
)
.route(
"/api/v1/campaign/:campaign_id/role",
post(CampaignHandler::create_role),
Expand Down
86 changes: 86 additions & 0 deletions backend/server/src/models/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use snowflake::SnowflakeIdGenerator;
use sqlx::{FromRow, Postgres, Transaction};
use sqlx::types::Json;
use std::ops::DerefMut;
use axum::{async_trait, RequestPartsExt};
use axum::extract::{FromRef, FromRequestParts, Path};
use axum::http::request::Parts;
use crate::models::app::AppState;
use crate::models::question::MultiOptionQuestionOption;
use crate::models::rating::RatingDetails;
use crate::service::answer::assert_answer_application_is_open;
use crate::service::application::{assert_application_is_open};

Expand Down Expand Up @@ -173,6 +176,30 @@ pub enum ApplicationStatus {
Successful,
}

/// Individual rating data for a specific application.
///
/// Each row represents an individual rating for a single applicant in a given role
/// within a specific application, including reviewer information.
#[derive(Deserialize, Serialize, FromRow)]
pub struct ApplicationRatingSummary {
/// Application ID
#[serde(serialize_with = "crate::models::serde_string::serialize")]
pub application_id: i64,
/// Role IDs application is for
#[serde(serialize_with = "crate::models::serde_string::serialize_vec")]
pub applied_roles: Vec<i64>,
/// User's name (applicant)
pub user_name: String,
/// User's email (applicant)
pub user_email: String,
/// Status of the application
pub status: ApplicationStatus,
/// When the rating was last updated
pub updated_at: DateTime<Utc>,
/// All ratings the application has received
pub ratings: Option<sqlx::types::Json<Vec<RatingDetails>>>,
}

impl Application {
/// Creates a new application if it doesn't exist, otherwise returns the existing application ID.
///
Expand Down Expand Up @@ -535,6 +562,65 @@ impl Application {
Ok(application_details_list)
}

pub async fn get_application_ratings_summary(
campaign_id: i64,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<Vec<ApplicationRatingSummary>, ChaosError> {
let application_users_avg_ratings = sqlx::query_as!(
ApplicationRatingSummary,
"
SELECT
a.id AS application_id,
ARRAY_AGG(DISTINCT applied_roles.campaign_role_id) AS \"applied_roles!: Vec<i64>\",
u.name AS user_name, u.email AS user_email,
a.status AS \"status: ApplicationStatus\", a.updated_at,

to_jsonb(
coalesce(
array_remove(
array_agg(
jsonb_build_object(
'id', ar.id,
'rater_id', reviewer.id,
'rater_name', reviewer.name,
'rating', ar.rating,
'comment', ar.comment,
'updated_at', ar.updated_at
) ORDER BY ar.updated_at DESC
) FILTER (WHERE ar.id IS NOT NULL),
NULL
),
'{}'
)
) AS \"ratings: Json<Vec<RatingDetails>>\"
FROM applications a
JOIN application_roles applied_roles ON applied_roles.application_id = a.id
JOIN campaign_roles ON campaign_roles.id = applied_roles.campaign_role_id
LEFT JOIN application_ratings ar on ar.application_id = a.id
JOIN users u ON u.id = a.user_id
LEFT JOIN users AS reviewer ON reviewer.id = ar.rater_id
WHERE a.campaign_id = $1 AND a.submitted = true
GROUP BY a.id, u.name, u.email, a.status, a.updated_at
ORDER BY a.id ASC
",
campaign_id,
)
.fetch_all(transaction.deref_mut())
.await?;

// to_jsonb(
// array_agg(
// jsonb_build_object(
// 'id', mod.id,
// 'display_order', mod.display_order,
// 'text', mod.text
// ) ORDER BY mod.display_order
// ) FILTER (WHERE mod.id IS NOT NULL)
// ) AS "multi_option_data: Json<Vec<MultiOptionQuestionOption>>"

Ok(application_users_avg_ratings)
}

/// Retrieves all applications submitted by a specific user.
///
/// # Arguments
Expand Down
34 changes: 33 additions & 1 deletion backend/server/src/models/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::models::error::ChaosError;
use crate::service::answer::user_is_answer_owner;
use crate::service::application::{user_is_application_admin, user_is_application_owner};
use crate::service::auth::{assert_is_super_user, extract_user_id_from_request};
use crate::service::campaign::user_is_campaign_admin;
use crate::service::campaign::{user_is_campaign_admin, user_is_campaign_org_member};
use crate::service::email_template::user_is_email_template_admin;
use crate::service::offer::{assert_user_is_offer_admin, assert_user_is_offer_recipient};
use crate::service::organisation::assert_user_is_organisation_admin;
Expand Down Expand Up @@ -211,6 +211,38 @@ where
}
}

pub struct CampaignOrgMember {
/// ID of the member of the campaign's organisation
pub user_id: i64,
}

#[async_trait]
impl<S> FromRequestParts<S> for CampaignOrgMember
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = ChaosError;

async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let user_id = extract_user_id_from_request(parts, &app_state).await?;

let campaign_id = *parts
.extract::<Path<HashMap<String, i64>>>()
.await
.map_err(|_| ChaosError::BadRequest)?
.get("campaign_id")
.ok_or(ChaosError::BadRequest)?;

let mut tx = app_state.db.begin().await?;
user_is_campaign_org_member(user_id, campaign_id, &mut tx).await?;
tx.commit().await?;

Ok(CampaignOrgMember { user_id })
}
}

/// Role administrator information.
///
/// Contains the user ID of a user with role administrator privileges.
Expand Down
28 changes: 28 additions & 0 deletions backend/server/src/service/campaign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,34 @@ pub async fn user_is_campaign_admin(
Ok(())
}

pub async fn user_is_campaign_org_member(
user_id: i64,
campaign_id: i64,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<(), ChaosError> {
let is_admin = sqlx::query!(
"
SELECT EXISTS(
SELECT 1 FROM campaigns c
JOIN organisation_members m on c.organisation_id = m.organisation_id
WHERE c.id = $1 AND m.user_id = $2
)
",
campaign_id,
user_id
)
.fetch_one(transaction.deref_mut())
.await?
.exists
.expect("`exists` should always exist in this query result");

if !is_admin {
return Err(ChaosError::Unauthorized);
}

Ok(())
}

/// Verifies if a campaign is still open for applications.
///
/// This function checks if the campaign deadline has not passed.
Expand Down
Loading
Loading