Skip to content

Commit 501b4bd

Browse files
authored
Merge pull request #696 from devsoc-unsw/CHAOS-695-individual-application-review-page
Chaos 695 individual application review page
2 parents 240d9c5 + 98af5be commit 501b4bd

29 files changed

Lines changed: 1246 additions & 137 deletions

File tree

backend/server/src/handler/application.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
use crate::models::app::{AppMessage, AppState};
1010
use crate::models::application::{Application, ApplicationRoleUpdate, ApplicationStatus, OpenApplicationByApplicationId};
1111
use crate::models::auth::{ApplicationAdmin, ApplicationOwner, ApplicationOwnerOrReviewer, ApplicationReviewerGivenApplicationId, AuthUser, CampaignAdmin, CampaignOrgMember};
12+
use crate::models::email::EmailQueue;
1213
use crate::models::error::ChaosError;
1314
use crate::models::transaction::DBTransaction;
1415
use axum::extract::{Json, Path, State};
1516
use axum::http::StatusCode;
1617
use axum::response::IntoResponse;
18+
use serde::Deserialize;
1719
use serde_json::json;
1820
use crate::models::rating::{NewRating, NewApplicationRating, Rating};
1921

@@ -418,7 +420,7 @@ impl ApplicationHandler {
418420
///
419421
/// * `Result<impl IntoResponse, ChaosError>` - List of average ratings or error
420422
pub async fn get_application_ratings_summary(
421-
_: CampaignOrgMember,
423+
_: CampaignAdmin,
422424
Path(campaign_id): Path<i64>,
423425
mut transaction: DBTransaction<'_>,
424426
) -> Result<impl IntoResponse, ChaosError> {

backend/server/src/handler/comment.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//! This module provides HTTP request handlers for CRUD operations on application comments.
44
55
use crate::models::app::{AppMessage, AppState};
6-
use crate::models::auth::ApplicationReviewerGivenApplicationId;
6+
use crate::models::auth::{ApplicationReviewerGivenApplicationId, CommentAuthorGivenApplicationAndCommentId};
77
use crate::models::comment::{Comment, NewComment, UpdateComment};
88
use crate::models::error::ChaosError;
99
use crate::models::transaction::DBTransaction;
@@ -59,7 +59,7 @@ impl CommentHandler {
5959
/// Returns an OK message on success.
6060
pub async fn edit_comment(
6161
Path((application_id, comment_id)): Path<(i64, i64)>,
62-
admin: ApplicationReviewerGivenApplicationId,
62+
admin: CommentAuthorGivenApplicationAndCommentId,
6363
mut transaction: DBTransaction<'_>,
6464
Json(data): Json<UpdateComment>,
6565
) -> Result<impl IntoResponse, ChaosError> {
@@ -89,7 +89,7 @@ impl CommentHandler {
8989
/// Returns an OK message on success.
9090
pub async fn delete_comment(
9191
Path((application_id, comment_id)): Path<(i64, i64)>,
92-
admin: ApplicationReviewerGivenApplicationId,
92+
admin: CommentAuthorGivenApplicationAndCommentId,
9393
mut transaction: DBTransaction<'_>,
9494
) -> Result<impl IntoResponse, ChaosError> {
9595
Comment::delete(
@@ -104,5 +104,9 @@ impl CommentHandler {
104104

105105
Ok(AppMessage::OkMessage("Successfully deleted comment"))
106106
}
107+
108+
// pub async fn get_comments_by_application(
109+
110+
// )
107111
}
108112

backend/server/src/models/app.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::handler::answer::AnswerHandler;
22
use crate::handler::application::ApplicationHandler;
33
use crate::handler::auth::{DevLoginHandler, google_auth_init, google_callback, logout};
44
use crate::handler::campaign::CampaignHandler;
5+
use crate::handler::comment::CommentHandler;
56
use crate::handler::email_template::EmailTemplateHandler;
67
use crate::handler::offer::OfferHandler;
78
use crate::handler::organisation::OrganisationHandler;
@@ -487,7 +488,10 @@ pub async fn app() -> Result<(Router, AppState), ChaosError> {
487488
"/api/v1/offer/:offer_id/send",
488489
post(OfferHandler::send_offer),
489490
)
490-
491+
.route(
492+
"/api/v1/comment/create",
493+
post(CommentHandler::create_comment),
494+
)
491495
// Invite routes
492496
// - GET /api/v1/invite/:code -> invite details
493497
// - POST /api/v1/invite/:code -> accept invite

backend/server/src/models/application.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ pub struct ApplicationRatingSummary {
208208
pub user_email: String,
209209
/// Status of the application
210210
pub status: ApplicationStatus,
211+
/// Private status (internal accept/reject)
212+
pub private_status: ApplicationStatus,
211213
/// When the rating was last updated
212214
pub updated_at: DateTime<Utc>,
213215
/// All ratings the application has received
@@ -675,7 +677,9 @@ impl Application {
675677
a.id AS application_id,
676678
ARRAY_AGG(DISTINCT applied_roles.campaign_role_id) AS \"applied_roles!: Vec<i64>\",
677679
u.name AS user_name, u.email AS user_email,
678-
a.status AS \"status: ApplicationStatus\", a.updated_at,
680+
a.status AS \"status: ApplicationStatus\",
681+
a.private_status AS \"private_status: ApplicationStatus\",
682+
a.updated_at,
679683
680684
coalesce(
681685
to_jsonb(
@@ -713,7 +717,7 @@ impl Application {
713717
JOIN users u ON u.id = a.user_id
714718
LEFT JOIN users AS reviewer ON reviewer.id = ar.rater_id
715719
WHERE a.campaign_id = $1 AND a.submitted = true
716-
GROUP BY a.id, u.name, u.email, a.status, a.updated_at
720+
GROUP BY a.id, u.name, u.email, a.status, a.private_status, a.updated_at
717721
ORDER BY a.id ASC
718722
",
719723
campaign_id,

backend/server/src/models/auth.rs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use crate::service::rating::{
1919
assert_user_is_rating_creator_and_organisation_member,
2020
};
2121
use crate::service::role::user_is_role_admin;
22+
use crate::service::comment::user_is_comment_author;
2223
use axum::extract::{FromRef, FromRequestParts, Path};
2324
use axum::http::request::Parts;
2425
use axum::response::{IntoResponse, Redirect, Response};
@@ -629,6 +630,50 @@ where
629630
}
630631
}
631632

633+
/// Comment author information for a specific comment.
634+
///
635+
/// Ensures the request user is the author of the given comment.
636+
pub struct CommentAuthorGivenApplicationAndCommentId {
637+
/// ID of the comment author
638+
pub user_id: i64,
639+
}
640+
641+
/// Extractor for comment authors.
642+
///
643+
/// This extractor validates that the authenticated user is the `comments.author_id`
644+
/// for the provided `(application_id, comment_id)` route parameters.
645+
#[async_trait]
646+
impl<S> FromRequestParts<S> for CommentAuthorGivenApplicationAndCommentId
647+
where
648+
AppState: FromRef<S>,
649+
S: Send + Sync,
650+
{
651+
type Rejection = ChaosError;
652+
653+
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
654+
let app_state = AppState::from_ref(state);
655+
let user_id = extract_user_id_from_request(parts, &app_state).await?;
656+
657+
let Path(ids) = parts
658+
.extract::<Path<HashMap<String, i64>>>()
659+
.await
660+
.map_err(|_| ChaosError::BadRequest)?;
661+
662+
let comment_id = ids.get("comment_id").ok_or(ChaosError::BadRequest)?.clone();
663+
664+
let mut tx = app_state.db.begin().await?;
665+
let is_owner = user_is_comment_author(user_id, comment_id, &mut tx).await?;
666+
667+
if !is_owner {
668+
return Err(ChaosError::Unauthorized);
669+
}
670+
671+
tx.commit().await?;
672+
673+
Ok(CommentAuthorGivenApplicationAndCommentId { user_id })
674+
}
675+
}
676+
632677
/// Application owner or a reviewer (member of organisation that application was for).
633678
///
634679
/// Contains the user ID of a user who owns a specific application.
@@ -721,7 +766,7 @@ pub struct OfferAdmin {
721766
}
722767

723768
/// Extractor for offer administrators.
724-
///
769+
///
725770
/// This extractor is used in route handlers to ensure that the request
726771
/// comes from a user with offer administrator privileges.
727772
#[async_trait]

backend/server/src/models/comment.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ pub struct Comment {
2727
pub created_at: DateTime<Utc>,
2828
}
2929

30+
/// A comment authored by a user on a specific application for frontend representation
31+
#[derive(Deserialize, Serialize, Clone, FromRow, Debug)]
32+
pub struct CommentDetails {
33+
/// Unique identifier for the comment.
34+
#[serde(serialize_with = "crate::models::serde_string::serialize")]
35+
pub id: i64,
36+
/// The name of the author.
37+
pub name: String,
38+
/// The comment body.
39+
pub body: String,
40+
/// The user who authored the comment.
41+
#[serde(serialize_with = "crate::models::serde_string::serialize")]
42+
pub author_id: i64,
43+
/// When the comment was created.
44+
pub created_at: DateTime<Utc>,
45+
}
46+
3047
/// Request payload for creating a new comment.
3148
#[derive(Deserialize, Serialize)]
3249
pub struct NewComment {
@@ -110,6 +127,30 @@ impl Comment {
110127
Ok(comment)
111128
}
112129

130+
/// Updates an existing comment.
131+
///
132+
/// The update is scoped to both the comment ID and the author, so users can only edit their own comments.
133+
///
134+
/// # Arguments
135+
/// * `application_id` - The application the comment belongs to (used for extra scoping/consistency).
136+
/// * `transaction` - Database transaction to use.
137+
pub async fn get_comments_by_application(
138+
application_id: i64,
139+
transaction: &mut Transaction<'_, Postgres>,
140+
) -> Result<Vec<CommentDetails>, ChaosError> {
141+
let application_comments_by_postdate = sqlx::query_as!(
142+
CommentDetails,
143+
"SELECT u.name, c.id, c.author_id, c.body, c.created_at FROM comments c INNER JOIN
144+
users u ON c.author_id = u.id
145+
WHERE c.application_id = $1
146+
ORDER BY c.created_at ASC",
147+
application_id
148+
).fetch_all(transaction.deref_mut())
149+
.await?;
150+
151+
Ok(application_comments_by_postdate)
152+
}
153+
113154
/// Updates an existing comment.
114155
///
115156
/// The update is scoped to both the comment ID and the author, so users can only edit their own comments.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//! Comment service for the Chaos application.
2+
//!
3+
//! This module provides functionality for managing comments, including:
4+
//! - Verifying comment owner
5+
use chrono::Utc;
6+
use crate::models::error::ChaosError;
7+
use sqlx::{Postgres, Transaction};
8+
use std::ops::DerefMut;
9+
10+
/// Verifies if a user has owner privileges for a comment
11+
///
12+
/// This function checks if the user is the author of a comment and can thus edit/delete it
13+
///
14+
/// # Arguments
15+
///
16+
/// * `user_id` - The ID of the user to check
17+
/// * `comment_id` - The ID of the comment
18+
/// * `pool` - Database connection pool
19+
///
20+
/// # Returns
21+
///
22+
/// * `Result<(), ChaosError>` - Ok if the user is an admin, Unauthorized error otherwise
23+
pub async fn user_is_comment_author(
24+
user_id: i64,
25+
comment_id: i64,
26+
transaction: &mut Transaction<'_, Postgres>,
27+
) -> Result<bool, ChaosError> {
28+
let is_author = sqlx::query!(
29+
"
30+
SELECT EXISTS(
31+
SELECT 1 FROM (
32+
SELECT u.id FROM users u
33+
JOIN comments c on c.author_id = $1
34+
WHERE c.id = $2
35+
)
36+
)
37+
",
38+
user_id,
39+
comment_id
40+
)
41+
.fetch_one(transaction.deref_mut())
42+
.await?
43+
.exists
44+
.expect("`exists` should always exist in this query result");
45+
46+
if !is_author {
47+
return Ok(false);
48+
}
49+
50+
Ok(true)
51+
}

backend/server/src/service/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
//! - `application`: Handles application creation, updates, and retrieval
88
//! - `auth`: Manages authentication and authorization
99
//! - `campaign`: Handles campaign-related operations
10+
//! //! - `comment`: Handles commentrelated operations
1011
//! - `email_template`: Manages email template operations
1112
//! - `jwt`: Handles JWT token generation and validation
1213
//! - `oauth2`: Manages OAuth2 authentication flow
@@ -20,6 +21,7 @@ pub mod answer;
2021
pub mod application;
2122
pub mod auth;
2223
pub mod campaign;
24+
pub mod comment;
2325
pub mod email_template;
2426
pub mod jwt;
2527
pub mod oauth2;

frontend-nextjs/src/app/[lang]/campaign/apply/[campaignId]/application/[applicationId]/application-answer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ export default function ApplicationReview({
251251
}
252252
}, [selectedRoleIds, campaignId, applicationId, queryClient]);
253253

254-
return (
254+
return (
255255
<div className="min-h-screen w-full overflow-x-hidden bg-background">
256256
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
257257
<div className="mb-5 sm:mb-8">
@@ -281,4 +281,4 @@ export default function ApplicationReview({
281281
</div>
282282
</div>
283283
);
284-
}
284+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import ApplicationRatingsSection from "@/components/application-details/application-ratings-section/application-ratings-section";
5+
import ApplicationDetailsComponent from "../../review/application-details";
6+
import CommentsSection from "@/components/application-details/comments-section/comments-section";
7+
import { Separator } from "@radix-ui/react-select";
8+
9+
type Props = {
10+
applicationId: string;
11+
campaignId: string;
12+
dict: any;
13+
};
14+
15+
export default function ApplicationOverviewPageComponent({
16+
applicationId,
17+
campaignId,
18+
dict,
19+
}: Props) {
20+
const [ratedApplications, setRatedApplications] = useState<Record<string, boolean>>(
21+
{},
22+
);
23+
24+
return (
25+
<div className="flex flex-col gap-4">
26+
<ApplicationDetailsComponent
27+
applicationId={applicationId}
28+
campaignId={campaignId}
29+
dict={dict}
30+
ratedApplications={ratedApplications}
31+
setRatedApplications={setRatedApplications}>
32+
<ApplicationRatingsSection
33+
applicationId={applicationId}
34+
campaignId={campaignId}
35+
dict={dict}
36+
/>
37+
<Separator className="my-4" />
38+
<CommentsSection/>
39+
</ApplicationDetailsComponent>
40+
</div>
41+
);
42+
}

0 commit comments

Comments
 (0)