Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
5 changes: 5 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ jobs:
echo "SMTP_USERNAME=test_username" >> backend/.env
echo "SMTP_PASSWORD=test_password" >> backend/.env
echo "SMTP_HOST=smtp.example.com" >> backend/.env
echo "CAMPAIGN_NAME_MAX_CHARS=50" >> backend/.env
echo "CAMPAIGN_DESCRIPTION_MAX_ChARS=500" >> backend/.env
echo "ROLE_NAME_MAX_CHARS=20" >> backend/.env
echo "ROLE_DESCRIPTION_MAX_CHARS=30" >> backend/.env
echo "ROLE_POSITIONS_AVAILABLE_MAX=20" >> backend/.env
# selecting a toolchain either by action or manual `rustup` calls should happen
# before the plugin, as it uses the current rustc version as its cache key
- uses: actions-rs/toolchain@v1
Expand Down
26 changes: 26 additions & 0 deletions backend/server/src/models/campaign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use uuid::Uuid;
use crate::models::app::AppState;
use crate::service::campaign::assert_campaign_is_open;
use super::{error::ChaosError, storage::Storage};
use std::env;

/// Represents a campaign in the system.
///
Expand Down Expand Up @@ -307,6 +308,8 @@ impl Campaign {
update: CampaignUpdate,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<(), ChaosError> {
update.validate()?;

_ = sqlx::query!(
"
UPDATE campaigns
Expand Down Expand Up @@ -448,3 +451,26 @@ where
Ok(OpenCampaign)
}
}


impl CampaignUpdate {
pub fn validate(&self) -> Result<(), ChaosError> {
let campaign_name_max_chars = env::var("CAMPAIGN_NAME_MAX_CHARS")
.expect("Error getting CAMPAIGN_NAME_MAX_CHARS")
.to_string().parse::<usize>().map_err(|_| ChaosError::InternalServerError)?;
let campaign_description_max_chars = env::var("CAMPAIGN_DESCRIPTION_MAX_CHARS")
.expect("Error getting CAMPAIGN_DESCRIPTION_MAX_CHARS")
.to_string().parse::<usize>().map_err(|_| ChaosError::InternalServerError)?;

if self.name.len() > campaign_name_max_chars ||
self.description.len() > campaign_description_max_chars ||
self.name.is_empty() ||
self.slug.is_empty() ||
self.starts_at >= self.ends_at {
return Err(ChaosError::BadRequest);
}

// TODO: update to ensure one day apart min to match frontend
Ok(())
}
}
4 changes: 4 additions & 0 deletions backend/server/src/models/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ pub enum ChaosError {
/// SMTP transport failed
#[error("SMTP transport error")]
SmtpTransportError(#[from] lettre::transport::smtp::Error),

// not covered by any other error
#[error("Internal server error")]
InternalServerError,
}

/// Implementation for converting errors into HTTP responses.
Expand Down
34 changes: 34 additions & 0 deletions backend/server/src/models/role.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
use snowflake::SnowflakeIdGenerator;
use sqlx::{FromRow, Postgres, Transaction};
use std::ops::DerefMut;
use std::env;

/// Represents a role in a recruitment campaign.
///
Expand Down Expand Up @@ -99,6 +100,7 @@ impl Role {
transaction: &mut Transaction<'_, Postgres>,
snowflake_generator: &mut SnowflakeIdGenerator,
) -> Result<i64, ChaosError> {
role_data.validate()?;
let id = snowflake_generator.real_time_generate();

sqlx::query!(
Expand Down Expand Up @@ -185,6 +187,8 @@ impl Role {
role_data: RoleUpdate,
transaction: &mut Transaction<'_, Postgres>,
) -> Result<(), ChaosError> {
role_data.validate()?;

let _ = sqlx::query!(
"
UPDATE campaign_roles
Expand Down Expand Up @@ -233,3 +237,33 @@ impl Role {
Ok(roles)
}
}

impl RoleUpdate {
pub fn validate(&self) -> Result<(), ChaosError> {
let role_name_max_chars = env::var("ROLE_NAME_MAX_CHARS")
.expect("Error getting ROLE_NAME_MAX_CHARS")
.to_string().parse::<usize>().map_err(|_| ChaosError::InternalServerError)?;
let role_description_max_chars = env::var("ROLE_DESCRIPTION_MAX_CHARS")
.expect("Error getting ROLE_DESCRIPTION_MAX_CHARS")
.to_string().parse::<usize>().map_err(|_| ChaosError::InternalServerError)?;
let role_positions_available_max = env::var("ROLE_POSITIONS_AVAILABLE_MAX")
.expect("Error getting ROLE_POSITIONS_AVAILABLE_MAX")
.to_string().parse::<i32>().map_err(|_| ChaosError::InternalServerError)?;

if self.name.is_empty() ||
self.min_available < 1 ||
self.max_available < 1 ||
self.min_available > self.max_available ||
self.name.len() > role_name_max_chars ||
self.min_available > role_positions_available_max ||
self.max_available > role_positions_available_max {
return Err(ChaosError::BadRequest);
}

if self.description.is_some() && self.description.as_ref().unwrap().len() > role_description_max_chars {
return Err(ChaosError::BadRequest);
}

Ok(())
}
}
9 changes: 8 additions & 1 deletion frontend-nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
"start": "next start"
},
"dependencies": {
"@lexical/react": "^0.38.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
Expand All @@ -23,14 +25,19 @@
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.555.0",
"date-fns": "^4.1.0",
"lexical": "^0.38.2",
"lucide-react": "^0.554.0",
"moment": "^2.30.1",
"next": "16.0.2",
"next-themes": "^0.4.6",
"react": "19.2.0",
"react-day-picker": "^9.11.3",
"react-dom": "19.2.0",
"react-resizable-panels": "^3.0.6",
"remark": "^15.0.1",
"remark-html": "^16.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import EditDetail from "@/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/edit-detail";
import { Button } from "@/components/ui/button";
import { dateToString } from "@/lib/utils";
import { ChevronDownIcon } from "lucide-react"
import { type DateRange } from "react-day-picker"
import { Calendar } from "@/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { useState, useRef } from "react";
import type { CampaignUpdateKeys } from "./campaign-details";

interface CampaignDatesProps {
starts_at: string;
ends_at: string;
editingMode: boolean;
onUpdate: (data: string | Date | undefined, key: CampaignUpdateKeys) => void;
isError: boolean;
setIsError: React.Dispatch<React.SetStateAction<boolean>>;
}

export default function CampaignDates({ starts_at, ends_at, editingMode, onUpdate, isError, setIsError }: CampaignDatesProps) {
const [range, setRange] = useState<DateRange>({
from: new Date(starts_at),
to: new Date(ends_at),
});

const rangeRef = useRef(range);
rangeRef.current = range;

if (editingMode) {
const updateDateRange = (date: DateRange | null) => {
if (date && date.from && date.to) {
if (isError && setIsError) {
setIsError(false);
}
const newFrom = new Date(date.from);
const newTo = new Date(date.to);

const currentRange = rangeRef.current;
if (currentRange.from) {
newFrom.setHours(
currentRange.from.getHours(),
currentRange.from.getMinutes(),
);
}
if (currentRange.to) {
newTo.setHours(
currentRange.to.getHours(),
currentRange.to.getMinutes(),
);
}

if (date.to !== currentRange.to) {
onUpdate(newTo, "endsAt");
}
if (date.from !== currentRange.from) {
onUpdate(newFrom, "startsAt");
}

setRange({
from: newFrom,
to: newTo,
});
}
}

const updateStartTime = (time: string) => {
if (isError && setIsError) {
setIsError(false);
}
const [hours, minutes] = time.split(":").map(Number);

const currentRange = rangeRef.current;
if (!currentRange.from) return;

const newStart = new Date(currentRange.from);
newStart.setHours(hours, minutes);

onUpdate(newStart, "startsAt");

setRange({
...currentRange,
from: newStart,
});
}

const updateEndTime = (time: string) => {
if (isError && setIsError) {
setIsError(false);
}
const [hours, minutes] = time.split(":").map(Number);

const currentRange = rangeRef.current;
if (!currentRange.to) return;

const newEnd = new Date(currentRange.to);
newEnd.setHours(hours, minutes);

onUpdate(newEnd, "endsAt");

setRange({
...currentRange,
to: newEnd,
});
}

return (
<div className="w-fit">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
id="dates"
className={`justify-between w-auto ${isError ? "ring-2 ring-red-500" : ""}`}
>
{range?.from && range?.to
? `${dateToString(range.from.toLocaleString())} - ${dateToString(range.to.toLocaleString())}`
: "Select dates"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Card className="w-fit py-0!">
<CardContent className="px-0!">
<Calendar
mode="range"
required
selected={range}
captionLayout="dropdown"
onSelect={updateDateRange}
/>
</CardContent>
<CardFooter className="flex gap-2 border-t px-4 py-3! *:[div]:w-full">
<Input
id="time-from"
type="time"
step="60"
defaultValue={range?.from?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })}
className="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
onChange={(e) => updateStartTime(e.target.value)}
/>
<span>-</span>
<Input
id="time-to"
type="time"
step="60"
defaultValue={range?.to?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })}
className="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
onChange={(e) => updateEndTime(e.target.value)}
/>
</CardFooter>
</Card>
</PopoverContent>
</Popover>
</div>
);
}
return (
<p className="text-sm text-gray-500">{dateToString(starts_at)} - {dateToString(ends_at)}</p>
);
}
Loading
Loading