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/database-seeding/src/seeder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use server::models::rating::{Rating, NewRating, NewCategoryRating, NewApplicatio
use server::models::user::{User, UserRole};
use server::models::organisation::{Organisation};
use server::models::role::{Role, RoleUpdate};
use server::models::campaign::{Campaign};
use server::models::question::*;
use server::models::answer::*;
use server::models::application::{Application, NewApplication, ApplicationRole};
Expand Down Expand Up @@ -124,7 +125,8 @@ pub async fn seed_database(dev_email: String, mut seeder: Seeder) {
&mut seeder.app_state.snowflake_generator,
)
.await.expect("Failed seeding Campaign");


_ = Campaign::publish(campaign_id, &mut tx).await.expect("Failed publishing");

let role_id_1 = Role::create(
campaign_id,
Expand Down
33 changes: 14 additions & 19 deletions backend/server/src/handler/campaign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ impl CampaignHandler {
Path((organisation_slug, campaign_slug)): Path<(String, String)>,
) -> Result<impl IntoResponse, ChaosError> {
let campaign =
Campaign::get_by_slugs(organisation_slug, campaign_slug, &mut transaction.tx).await?;
Campaign::get_by_slugs(organisation_slug, campaign_slug, true, &mut transaction.tx).await?;

transaction.tx.commit().await?;
Ok((StatusCode::OK, Json(campaign)))
}
Expand Down Expand Up @@ -214,7 +215,7 @@ impl CampaignHandler {
_admin: CampaignAdmin,
Json(data): Json<RoleUpdate>,
) -> Result<impl IntoResponse, ChaosError> {
Role::create(id, data, &mut transaction.tx, &mut state.snowflake_generator).await?;
Campaign::create_role(id, data, &mut transaction.tx, &mut state.snowflake_generator).await?;
transaction.tx.commit().await?;
Ok(AppMessage::OkMessage("Successfully created role"))
}
Expand Down Expand Up @@ -466,28 +467,22 @@ impl CampaignHandler {
pub async fn delete_attachment(
mut transaction: DBTransaction<'_>,
State(state): State<AppState>,
Path(attachment_id): Path<i64>,
user: AuthUser,
Path((campaign_id, attachment_id)): Path<(i64, i64)>,
_admin: CampaignAdmin,
) -> Result<impl IntoResponse, ChaosError> {
// Get the attachment to find its campaign_id
let attachment = CampaignAttachment::get_by_id(attachment_id, &mut transaction.tx).await?;
let campaign = Campaign::get(attachment.campaign_id, &mut transaction.tx).await?;

// Verify the admin has access to this campaign
crate::service::campaign::user_is_campaign_admin(
user.user_id,
attachment.campaign_id,
&mut transaction.tx,
)
.await?;

// Delete the attachment from database
CampaignAttachment::delete(attachment_id, &mut transaction.tx).await?;

// Ensure the attachment actually belongs to the campaign in the path
if attachment.campaign_id != campaign_id {
return Err(ChaosError::BadRequest);
}

let (organisation_id, campaign_id) = CampaignAttachment::delete(attachment_id, &mut transaction.tx).await?;

// Delete the file from S3 storage
let storage_path = format!("/organisation/{}/campaign/{}/attachment/{}", campaign.organisation_id, campaign.id, attachment.id);
let storage_path = format!("/organisation/{}/campaign/{}/attachment/{}", organisation_id, campaign_id, attachment.id);
Storage::delete_file(storage_path, &state.storage_bucket).await?;

transaction.tx.commit().await?;
Ok(())
}
Expand Down
2 changes: 1 addition & 1 deletion backend/server/src/models/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ pub async fn app() -> Result<(Router, AppState), ChaosError> {
.patch(CampaignHandler::upload_attachments),
)
.route(
"/api/v1/campaign/attachment/:attachment_id",
"/api/v1/campaign/:campaign_id/attachment/:attachment_id",
delete(CampaignHandler::delete_attachment),
)
.route(
Expand Down
11 changes: 11 additions & 0 deletions backend/server/src/models/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use axum::{async_trait, RequestPartsExt};
use axum::extract::{FromRef, FromRequestParts, Path};
use axum::http::request::Parts;
use crate::models::app::AppState;
use crate::models::campaign::Campaign;
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 @@ -232,6 +233,11 @@ impl Application {
snowflake_generator: &mut SnowflakeIdGenerator,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<i64, ChaosError> {
let campaign = Campaign::get(campaign_id, transaction).await?;
if !campaign.published {
return Err(ChaosError::BadRequest);
}

// Check if application already exists
let application = sqlx::query!(
"
Expand Down Expand Up @@ -310,6 +316,11 @@ impl Application {
snowflake_generator: &mut SnowflakeIdGenerator,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<i64, ChaosError> {
let campaign = Campaign::get(campaign_id, transaction).await?;
if !campaign.published {
return Err(ChaosError::BadRequest);
}

let id = snowflake_generator.real_time_generate();

// Insert into table applications
Expand Down
60 changes: 52 additions & 8 deletions backend/server/src/models/campaign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use axum::extract::{FromRef, FromRequestParts, Path};
use axum::http::request::Parts;
use uuid::Uuid;
use crate::models::app::AppState;
use crate::models::role::{Role, RoleUpdate};
use crate::service::campaign::{assert_campaign_is_open, create_proper_slug};
use super::{error::ChaosError, storage::Storage};
use snowflake::SnowflakeIdGenerator;
Expand Down Expand Up @@ -387,6 +388,7 @@ impl Campaign {
///
/// * `organisation_slug` - Slug of the organization
/// * `campaign_slug` - Slug of the campaign
/// * `check_for_published` - If true, returns BadRequest when the campaign is not published
/// * `transaction` - Database transaction to use
///
/// # Returns
Expand All @@ -395,6 +397,7 @@ impl Campaign {
pub async fn get_by_slugs(
organisation_slug: String,
campaign_slug: String,
check_for_published: bool,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<CampaignDetails, ChaosError> {
let campaign = sqlx::query_as!(
Expand All @@ -416,6 +419,10 @@ impl Campaign {
.fetch_one(transaction.deref_mut())
.await?;

if check_for_published && !campaign.published {
return Err(ChaosError::BadRequest);
}

Ok(campaign)
}

Expand All @@ -435,6 +442,10 @@ impl Campaign {
update: CampaignUpdate,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<(), ChaosError> {
let campaign = Self::get(id, transaction).await?;
if campaign.published {
return Err(ChaosError::BadRequest);
}
update.validate()?;

_ = sqlx::query!(
Expand Down Expand Up @@ -477,6 +488,10 @@ impl Campaign {
id: i64,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<(), ChaosError> {
let campaign = Self::get(id, transaction).await?;
if campaign.published {
return Err(ChaosError::BadRequest);
}
_ = sqlx::query!(
"
UPDATE campaigns
Expand Down Expand Up @@ -507,6 +522,10 @@ impl Campaign {
transaction: &mut Transaction<'_, Postgres>,
storage_bucket: &Bucket,
) -> Result<CampaignBannerUpdate, ChaosError> {
let campaign = Self::get(id, transaction).await?;
if campaign.published {
return Err(ChaosError::BadRequest);
}
let dt = Utc::now();
let image_id = Uuid::new_v4();
let current_time = dt;
Expand Down Expand Up @@ -544,6 +563,10 @@ impl Campaign {
id: i64,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<(), ChaosError> {
let campaign = Self::get(id, transaction).await?;
if campaign.published {
return Err(ChaosError::BadRequest);
}
_ = sqlx::query!(
"
DELETE FROM campaigns WHERE id = $1 RETURNING id
Expand All @@ -555,6 +578,20 @@ impl Campaign {

Ok(())
}

/// Creates a new role in the campaign. Returns BadRequest if campaign is published.
pub async fn create_role(
campaign_id: i64,
role_data: RoleUpdate,
transaction: &mut Transaction<'_, Postgres>,
snowflake_generator: &mut SnowflakeIdGenerator,
) -> Result<i64, ChaosError> {
let campaign = Self::get(campaign_id, transaction).await?;
if campaign.published {
return Err(ChaosError::BadRequest);
}
Role::create(campaign_id, role_data, transaction, snowflake_generator).await
}
}

impl CampaignAttachment {
Expand Down Expand Up @@ -639,9 +676,11 @@ impl CampaignAttachment {
snowflake_generator: &mut SnowflakeIdGenerator,
storage_bucket: &Bucket,
) -> Result<Vec<AttachmentUpload>, ChaosError> {
// Check if attachment already exists
let existing = Self::get_by_campaign(campaign_id, transaction).await?;
let campaign = Campaign::get(campaign_id, transaction).await?;
if campaign.published {
return Err(ChaosError::BadRequest);
}
let existing = Self::get_by_campaign(campaign_id, transaction).await?;

let mut attachment_ids = Vec::new();
for file in files {
Expand Down Expand Up @@ -692,8 +731,8 @@ impl CampaignAttachment {
Ok(results)
}

/// Deletes an attachment.
///
/// Deletes an attachment. Returns BadRequest if campaign is published.
/// Returns (organisation_id, campaign_id) for the caller to build storage path.
/// # Arguments
///
/// * `attachment_id` - ID of the attachment to delete
Expand All @@ -705,8 +744,13 @@ impl CampaignAttachment {
pub async fn delete(
attachment_id: i64,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<(), ChaosError> {
let id = sqlx::query!(
) -> Result<(i64, i64), ChaosError> {
let attachment = Self::get_by_id(attachment_id, transaction).await?;
let campaign = Campaign::get(attachment.campaign_id, transaction).await?;
if campaign.published {
return Err(ChaosError::BadRequest);
}
_ = sqlx::query!(
"
DELETE FROM campaign_attachments WHERE id = $1 RETURNING id
",
Expand All @@ -715,7 +759,7 @@ impl CampaignAttachment {
.fetch_one(transaction.deref_mut())
.await?;

Ok(())
Ok((campaign.organisation_id, campaign.id))
}
}
/// Extractor for ensuring a campaign is open.
Expand Down Expand Up @@ -768,4 +812,4 @@ impl CampaignUpdate {
// TODO: update to ensure one day apart min to match frontend
Ok(())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { getCampaignBySlugs } from "@/models/campaign";
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import CampaignInfo from "./campaign-info";


export default async function CampaignPage({ params }: { params: Promise<{ orgSlug: string, campaignSlug: string, lang: string }> }) {
const { orgSlug, campaignSlug, lang } = await params;
const dict = await getDictionary(lang);
Expand All @@ -19,5 +18,4 @@ export default async function CampaignPage({ params }: { params: Promise<{ orgSl
<CampaignInfo orgSlug={orgSlug} campaignSlug={campaignSlug} dict={dict} lang={lang}/>
</HydrationBoundary>
)

}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { getCampaign, getCampaignRoles, getCampaignAttachments } from "@/models/campaign";
import { getCampaign, getCampaignRoles, getCampaignAttachments, publishCampaign } from "@/models/campaign";
import { getRatingCategories, RatingCategory } from "@/models/rating";
import { Button } from "@/components/ui/button";
import { Copy, Pencil, Trash, Share, BookOpenCheck, FormIcon, CircleCheck, FileText, BarChart } from "lucide-react";
import { Copy, Pencil, Trash, Share, BookOpenCheck, FormIcon, FileText, BarChart } from "lucide-react";
import { ButtonGroup } from "@/components/ui/button-group";
import { cn, dateToString } from "@/lib/utils";
import {
Expand All @@ -22,6 +22,7 @@ import { RoleDetails } from "@/models/campaign";
import { remark } from "remark";
import html from "remark-html";
import CopyButton from "@/components/copy-button";
import { PublishCampaignDialog } from "./publish-campaign-dialog";

interface ClientRole extends RoleDetails {
deleting: boolean;
Expand Down Expand Up @@ -65,7 +66,7 @@ function compareRatingCategories(categories: RatingCategory[], clientCategories:
}

export default function CampaignDetails({ campaignId, orgId, dict }: { campaignId: string, orgId: string, dict: any }) {
const { data: campaign } = useQuery({
const { data: campaign, refetch: refetchCampaign } = useQuery({
queryKey: [`${campaignId}-campaign-details`],
queryFn: () => getCampaign(campaignId),
});
Expand Down Expand Up @@ -95,6 +96,15 @@ export default function CampaignDetails({ campaignId, orgId, dict }: { campaignI
const [descriptionHtmlState, setDescriptionHtmlState] = useState<string>("");
const [hoveredDeleteCategoryIndex, setHoveredDeleteCategoryIndex] = useState<number | null>(null);

const handlePublish = async () => {
try {
await publishCampaign(campaignId);
await refetchCampaign();
} catch (error) {
console.error("Failed to publish campaign:", error);
}
};

useEffect(() => {
async function processMarkdown() {
if (campaign?.description) {
Expand Down Expand Up @@ -132,23 +142,36 @@ export default function CampaignDetails({ campaignId, orgId, dict }: { campaignI
</CopyButton>
</ButtonGroup>
{userRole?.role === "Admin" && (
<ButtonGroup>
<Link href={`/dashboard/organisation/${orgId}/campaigns/${campaignId}/edit`}>
<Button variant="outline" className="cursor-pointer"><Pencil className="w-4 h-4" /> {dict.dashboard.actions.edit}</Button>
</Link>
<Button variant="outline" className="cursor-pointer"><Trash className="w-4 h-4" /> {dict.dashboard.actions.delete}</Button>
</ButtonGroup>
<>
<ButtonGroup>
<Link href={`/dashboard/organisation/${orgId}/campaigns/${campaignId}/edit`}>
<Button variant="outline" className="cursor-pointer">
<Pencil className="w-4 h-4" /> {dict.dashboard.actions.edit}
</Button>
</Link>
<Button variant="outline" className="cursor-pointer">
<Trash className="w-4 h-4" /> {dict.dashboard.actions.delete}
</Button>
</ButtonGroup>
{!campaign?.published && (
<>
<ButtonGroup>
<Link href={`/dashboard/organisation/${orgId}/campaigns/${campaignId}/questions`}>
<Button variant="outline">
<FormIcon className="w-4 h-4" /> {dict.dashboard.campaigns.manage_questions}
</Button>
</Link>
</ButtonGroup>
<ButtonGroup>
<PublishCampaignDialog
onPublish={handlePublish}
label={dict.dashboard.campaigns.publish}
/>
</ButtonGroup>
</>
)}
</>
)}
{
!campaign?.published && (
<Link href={`/dashboard/organisation/${orgId}/campaigns/${campaignId}/questions`}>
<Button variant="outline"><FormIcon className="w-4 h-4" /> {dict.dashboard.campaigns.manage_questions}</Button>
</Link>
)
}
<ButtonGroup>
<Button variant="outline"><CircleCheck className="w-4 h-4 text-green-500" /> {dict.dashboard.campaigns.publish}</Button>
</ButtonGroup>

</ButtonGroup>
</div>
Expand Down
Loading
Loading