Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion backend/server/src/handler/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
use crate::models::app::{AppMessage, AppState};
use crate::models::application::{Application, ApplicationRoleUpdate, ApplicationStatus, OpenApplicationByApplicationId};
use crate::models::auth::{ApplicationAdmin, ApplicationOwner, ApplicationOwnerOrReviewer, ApplicationReviewerGivenApplicationId, AuthUser, CampaignAdmin, CampaignOrgMember};
use crate::models::email::EmailQueue;
use crate::models::error::ChaosError;
use crate::models::transaction::DBTransaction;
use axum::extract::{Json, Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::Deserialize;
use serde_json::json;
use crate::models::rating::{NewRating, NewApplicationRating, Rating};

Expand Down Expand Up @@ -418,7 +420,7 @@ impl ApplicationHandler {
///
/// * `Result<impl IntoResponse, ChaosError>` - List of average ratings or error
pub async fn get_application_ratings_summary(
_: CampaignOrgMember,
_: CampaignAdmin,
Path(campaign_id): Path<i64>,
mut transaction: DBTransaction<'_>,
) -> Result<impl IntoResponse, ChaosError> {
Expand Down
10 changes: 7 additions & 3 deletions backend/server/src/handler/comment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//! This module provides HTTP request handlers for CRUD operations on application comments.

use crate::models::app::{AppMessage, AppState};
use crate::models::auth::ApplicationReviewerGivenApplicationId;
use crate::models::auth::{ApplicationReviewerGivenApplicationId, CommentAuthorGivenApplicationAndCommentId};
use crate::models::comment::{Comment, NewComment, UpdateComment};
use crate::models::error::ChaosError;
use crate::models::transaction::DBTransaction;
Expand Down Expand Up @@ -59,7 +59,7 @@ impl CommentHandler {
/// Returns an OK message on success.
pub async fn edit_comment(
Path((application_id, comment_id)): Path<(i64, i64)>,
admin: ApplicationReviewerGivenApplicationId,
admin: CommentAuthorGivenApplicationAndCommentId,
mut transaction: DBTransaction<'_>,
Json(data): Json<UpdateComment>,
) -> Result<impl IntoResponse, ChaosError> {
Expand Down Expand Up @@ -89,7 +89,7 @@ impl CommentHandler {
/// Returns an OK message on success.
pub async fn delete_comment(
Path((application_id, comment_id)): Path<(i64, i64)>,
admin: ApplicationReviewerGivenApplicationId,
admin: CommentAuthorGivenApplicationAndCommentId,
mut transaction: DBTransaction<'_>,
) -> Result<impl IntoResponse, ChaosError> {
Comment::delete(
Expand All @@ -104,5 +104,9 @@ impl CommentHandler {

Ok(AppMessage::OkMessage("Successfully deleted comment"))
}

// pub async fn get_comments_by_application(

// )
}

6 changes: 5 additions & 1 deletion backend/server/src/models/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::handler::answer::AnswerHandler;
use crate::handler::application::ApplicationHandler;
use crate::handler::auth::{DevLoginHandler, google_auth_init, google_callback, logout};
use crate::handler::campaign::CampaignHandler;
use crate::handler::comment::CommentHandler;
use crate::handler::email_template::EmailTemplateHandler;
use crate::handler::offer::OfferHandler;
use crate::handler::organisation::OrganisationHandler;
Expand Down Expand Up @@ -487,7 +488,10 @@ pub async fn app() -> Result<(Router, AppState), ChaosError> {
"/api/v1/offer/:offer_id/send",
post(OfferHandler::send_offer),
)

.route(
"/api/v1/comment/create",
post(CommentHandler::create_comment),
)
// Invite routes
// - GET /api/v1/invite/:code -> invite details
// - POST /api/v1/invite/:code -> accept invite
Expand Down
8 changes: 6 additions & 2 deletions backend/server/src/models/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ pub struct ApplicationRatingSummary {
pub user_email: String,
/// Status of the application
pub status: ApplicationStatus,
/// Private status (internal accept/reject)
pub private_status: ApplicationStatus,
/// When the rating was last updated
pub updated_at: DateTime<Utc>,
/// All ratings the application has received
Expand Down Expand Up @@ -675,7 +677,9 @@ impl Application {
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,
a.status AS \"status: ApplicationStatus\",
a.private_status AS \"private_status: ApplicationStatus\",
a.updated_at,

coalesce(
to_jsonb(
Expand Down Expand Up @@ -713,7 +717,7 @@ impl Application {
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
GROUP BY a.id, u.name, u.email, a.status, a.private_status, a.updated_at
ORDER BY a.id ASC
",
campaign_id,
Expand Down
47 changes: 46 additions & 1 deletion backend/server/src/models/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use crate::service::rating::{
assert_user_is_rating_creator_and_organisation_member,
};
use crate::service::role::user_is_role_admin;
use crate::service::comment::user_is_comment_author;
use axum::extract::{FromRef, FromRequestParts, Path};
use axum::http::request::Parts;
use axum::response::{IntoResponse, Redirect, Response};
Expand Down Expand Up @@ -629,6 +630,50 @@ where
}
}

/// Comment author information for a specific comment.
///
/// Ensures the request user is the author of the given comment.
pub struct CommentAuthorGivenApplicationAndCommentId {
/// ID of the comment author
pub user_id: i64,
}

/// Extractor for comment authors.
///
/// This extractor validates that the authenticated user is the `comments.author_id`
/// for the provided `(application_id, comment_id)` route parameters.
#[async_trait]
impl<S> FromRequestParts<S> for CommentAuthorGivenApplicationAndCommentId
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 Path(ids) = parts
.extract::<Path<HashMap<String, i64>>>()
.await
.map_err(|_| ChaosError::BadRequest)?;

let comment_id = ids.get("comment_id").ok_or(ChaosError::BadRequest)?.clone();

let mut tx = app_state.db.begin().await?;
let is_owner = user_is_comment_author(user_id, comment_id, &mut tx).await?;

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

tx.commit().await?;

Ok(CommentAuthorGivenApplicationAndCommentId { user_id })
}
}

/// Application owner or a reviewer (member of organisation that application was for).
///
/// Contains the user ID of a user who owns a specific application.
Expand Down Expand Up @@ -721,7 +766,7 @@ pub struct OfferAdmin {
}

/// Extractor for offer administrators.
///
///
/// This extractor is used in route handlers to ensure that the request
/// comes from a user with offer administrator privileges.
#[async_trait]
Expand Down
41 changes: 41 additions & 0 deletions backend/server/src/models/comment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ pub struct Comment {
pub created_at: DateTime<Utc>,
}

/// A comment authored by a user on a specific application for frontend representation
#[derive(Deserialize, Serialize, Clone, FromRow, Debug)]
pub struct CommentDetails {
/// Unique identifier for the comment.
#[serde(serialize_with = "crate::models::serde_string::serialize")]
pub id: i64,
/// The name of the author.
pub name: String,
/// The comment body.
pub body: String,
/// The user who authored the comment.
#[serde(serialize_with = "crate::models::serde_string::serialize")]
pub author_id: i64,
/// When the comment was created.
pub created_at: DateTime<Utc>,
}

/// Request payload for creating a new comment.
#[derive(Deserialize, Serialize)]
pub struct NewComment {
Expand Down Expand Up @@ -110,6 +127,30 @@ impl Comment {
Ok(comment)
}

/// Updates an existing comment.
///
/// The update is scoped to both the comment ID and the author, so users can only edit their own comments.
///
/// # Arguments
/// * `application_id` - The application the comment belongs to (used for extra scoping/consistency).
/// * `transaction` - Database transaction to use.
pub async fn get_comments_by_application(
application_id: i64,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<Vec<CommentDetails>, ChaosError> {
let application_comments_by_postdate = sqlx::query_as!(
CommentDetails,
"SELECT u.name, c.id, c.author_id, c.body, c.created_at FROM comments c INNER JOIN
users u ON c.author_id = u.id
WHERE c.application_id = $1
ORDER BY c.created_at ASC",
application_id
).fetch_all(transaction.deref_mut())
.await?;

Ok(application_comments_by_postdate)
}

/// Updates an existing comment.
///
/// The update is scoped to both the comment ID and the author, so users can only edit their own comments.
Expand Down
51 changes: 51 additions & 0 deletions backend/server/src/service/comment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//! Comment service for the Chaos application.
//!
//! This module provides functionality for managing comments, including:
//! - Verifying comment owner
use chrono::Utc;
use crate::models::error::ChaosError;
use sqlx::{Postgres, Transaction};
use std::ops::DerefMut;

/// Verifies if a user has owner privileges for a comment
///
/// This function checks if the user is the author of a comment and can thus edit/delete it
///
/// # Arguments
///
/// * `user_id` - The ID of the user to check
/// * `comment_id` - The ID of the comment
/// * `pool` - Database connection pool
///
/// # Returns
///
/// * `Result<(), ChaosError>` - Ok if the user is an admin, Unauthorized error otherwise
pub async fn user_is_comment_author(
user_id: i64,
comment_id: i64,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<bool, ChaosError> {
let is_author = sqlx::query!(
"
SELECT EXISTS(
SELECT 1 FROM (
SELECT u.id FROM users u
JOIN comments c on c.author_id = $1
WHERE c.id = $2
)
)
",
user_id,
comment_id
)
.fetch_one(transaction.deref_mut())
.await?
.exists
.expect("`exists` should always exist in this query result");

if !is_author {
return Ok(false);
}

Ok(true)
}
2 changes: 2 additions & 0 deletions backend/server/src/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//! - `application`: Handles application creation, updates, and retrieval
//! - `auth`: Manages authentication and authorization
//! - `campaign`: Handles campaign-related operations
//! //! - `comment`: Handles commentrelated operations
//! - `email_template`: Manages email template operations
//! - `jwt`: Handles JWT token generation and validation
//! - `oauth2`: Manages OAuth2 authentication flow
Expand All @@ -20,6 +21,7 @@ pub mod answer;
pub mod application;
pub mod auth;
pub mod campaign;
pub mod comment;
pub mod email_template;
pub mod jwt;
pub mod oauth2;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ export default function ApplicationReview({
}
}, [selectedRoleIds, campaignId, applicationId, queryClient]);

return (
return (
<div className="min-h-screen w-full overflow-x-hidden bg-background">
<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">
<div className="mb-5 sm:mb-8">
Expand Down Expand Up @@ -281,4 +281,4 @@ export default function ApplicationReview({
</div>
</div>
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client";

import { useState } from "react";
import ApplicationRatingsSection from "@/components/application-details/application-ratings-section/application-ratings-section";
import ApplicationDetailsComponent from "../../review/application-details";
import CommentsSection from "@/components/application-details/comments-section/comments-section";
import { Separator } from "@radix-ui/react-select";

type Props = {
applicationId: string;
campaignId: string;
dict: any;
};

export default function ApplicationOverviewPageComponent({
applicationId,
campaignId,
dict,
}: Props) {
const [ratedApplications, setRatedApplications] = useState<Record<string, boolean>>(
{},
);

return (
<div className="flex flex-col gap-4">
<ApplicationDetailsComponent
applicationId={applicationId}
campaignId={campaignId}
dict={dict}
ratedApplications={ratedApplications}
setRatedApplications={setRatedApplications}>
<ApplicationRatingsSection
applicationId={applicationId}
campaignId={campaignId}
dict={dict}
/>
<Separator className="my-4" />
<CommentsSection/>
</ApplicationDetailsComponent>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import ApplicationOverviewPageComponent from "./application-overview-page";
import { getDictionary } from "@/app/[lang]/dictionaries";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";

export default async function ApplicationOverviewPage({
params,
}: {
params: Promise<{
lang: string;
orgId: string;
campaignId: string;
applicationId: string;
}>;
}) {
const { lang, orgId, campaignId, applicationId } = await params;
const dict = await getDictionary(lang);

return (
<div className="space-y-4">
<div>
<Link
href={`/${lang}/dashboard/organisation/${orgId}/campaigns/${campaignId}/applications`}
>
<div className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="w-4 h-4" />
{dict.common.back}
</div>
</Link>
</div>
<ApplicationOverviewPageComponent
applicationId={applicationId}
campaignId={campaignId}
dict={dict}
/>
</div>
);
}

Loading
Loading