From 3f42ccd7934fd301cfb8a453d5658222811c50ef Mon Sep 17 00:00:00 2001 From: Kavika Date: Wed, 13 Aug 2025 22:27:16 +1000 Subject: [PATCH 01/46] get campaigns by org and campaign slugs --- backend/server/src/models/app.rs | 2 +- backend/server/src/models/campaign.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/server/src/models/app.rs b/backend/server/src/models/app.rs index 12a652b8..4bbf5415 100644 --- a/backend/server/src/models/app.rs +++ b/backend/server/src/models/app.rs @@ -250,7 +250,7 @@ pub async fn app() -> Result { .delete(CampaignHandler::delete), ) .route( - "/api/v1/campaign/slug/:organisation_slug/:campaign_slug", + "/api/v1/organisation/slug/:organisation_slug/campaign/slug/:campaign_slug", get(CampaignHandler::get_by_slugs), ) .route("/api/v1/campaigns", get(CampaignHandler::get_all)) diff --git a/backend/server/src/models/campaign.rs b/backend/server/src/models/campaign.rs index 446a63d0..c6ad8189 100644 --- a/backend/server/src/models/campaign.rs +++ b/backend/server/src/models/campaign.rs @@ -33,6 +33,8 @@ pub struct Campaign { pub name: String, /// ID of the organization running the campaign pub organisation_id: i64, + /// URL-friendly identifier for the organization + pub organisation_slug: String, /// Name of the organization running the campaign pub organisation_name: String, /// Optional UUID of the campaign's cover image @@ -157,7 +159,8 @@ impl Campaign { let campaigns = sqlx::query_as!( Campaign, " - SELECT c.*, o.name as organisation_name FROM campaigns c + SELECT c.*, o.name as organisation_name, o.slug as organisation_slug + FROM campaigns c JOIN organisations o on c.organisation_id = o.id " ) From 098f96e730a34865d10c088c25725ed3fe8f19bb Mon Sep 17 00:00:00 2001 From: Kavika Date: Wed, 13 Aug 2025 22:27:52 +1000 Subject: [PATCH 02/46] Update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6a3e68da..1f651bf7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -**/.DS_Store \ No newline at end of file +**/.DS_Store +**/.vscode/ \ No newline at end of file From b965ccc40aa83e865abbd47326c105ba6489c5da Mon Sep 17 00:00:00 2001 From: Kavika Date: Wed, 13 Aug 2025 23:13:35 +1000 Subject: [PATCH 03/46] campaign info page --- frontend/.env.development | 3 +- frontend/src/api/index.ts | 9 + .../src/components/CampaignCard/index.tsx | 7 +- frontend/src/pages/campaign/index.tsx | 238 ++++++++++++++++++ .../pages/dashboard/CampaignGrid/index.tsx | 2 + frontend/src/routes.tsx | 7 +- frontend/src/types/api.ts | 2 +- 7 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/campaign/index.tsx diff --git a/frontend/.env.development b/frontend/.env.development index de14f4b1..656be06c 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,3 +1,4 @@ -VITE_OAUTH_CALLBACK_URL=https://accounts.google.com/o/oauth2/v2/auth?client_id=731862014126-5b109p4v6b173910ib347gtfn0ecnacj.apps.googleusercontent.com&redirect_uri=http://localhost:8080/api/auth/callback/google&response_type=code&scope=https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile&access_type=offline +# VITE_OAUTH_CALLBACK_URL=https://accounts.google.com/o/oauth2/v2/auth?client_id=731862014126-5b109p4v6b173910ib347gtfn0ecnacj.apps.googleusercontent.com&redirect_uri=http://localhost:8080/api/auth/callback/google&response_type=code&scope=https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile&access_type=offline +VITE_OAUTH_CALLBACK_URL=http://localhost:8080/auth/google VITE_API_BASE_URL=http://localhost:8080 BROWSER=none diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e03b4bf3..6e6faff7 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -169,6 +169,15 @@ export const getCampaignBySlugs = (organisationSlug: string, campaignSlug: strin path: `/v1/campaign/slug/${organisationSlug}/${campaignSlug}`, }); +// Preferred explicit path for organisation + campaign slugs +export const getCampaignByOrgAndCampaignSlugs = ( + organisationSlug: string, + campaignSlug: string +) => + authenticatedRequest({ + path: `/v1/organisation/slug/${organisationSlug}/campaign/slug/${campaignSlug}`, + }); + export const getCampaignRoles = (campaignId: number) => authenticatedRequest({ path: `/v1/campaign/${campaignId}/roles`, diff --git a/frontend/src/components/CampaignCard/index.tsx b/frontend/src/components/CampaignCard/index.tsx index 663b9fa6..5cd98bd1 100644 --- a/frontend/src/components/CampaignCard/index.tsx +++ b/frontend/src/components/CampaignCard/index.tsx @@ -13,16 +13,19 @@ import type { CampaignWithRoles } from "types/api"; type AdminProps = { campaignId: number; + campaignSlug: string; isAdmin: true; }; type NonAdminProps = { campaignId?: number; + campaignSlug?: string; isAdmin?: false; }; type BaseProps = { organisationLogo?: string; + organisationSlug?: string; title: string; appliedFor: CampaignWithRoles["applied_for"]; positions: Position[]; @@ -37,7 +40,9 @@ type Props = BaseProps & (AdminProps | NonAdminProps); const CampaignCard = ({ campaignId, + campaignSlug, organisationLogo, + organisationSlug, title, appliedFor, positions, @@ -84,7 +89,7 @@ const CampaignCard = ({ const linkComponent = isAdmin ? ( {content} ) : ( - {content} + {content} ); return ( diff --git a/frontend/src/pages/campaign/index.tsx b/frontend/src/pages/campaign/index.tsx new file mode 100644 index 00000000..ce183ac8 --- /dev/null +++ b/frontend/src/pages/campaign/index.tsx @@ -0,0 +1,238 @@ +import { useEffect, useMemo, useState, useContext } from "react"; +import { Link, useParams } from "react-router-dom"; +import tw from "twin.macro"; + +import { getCampaignByOrgAndCampaignSlugs, getOrganisationBySlug, getCampaignRoles } from "api"; +import Container from "components/Container"; +import Card from "components/Card"; +import { LoadingIndicator } from "components"; +import { Button } from "components/ui/button"; +import { Badge } from "components/ui/badge"; +import { Calendar, Users, Clock, MapPin } from "lucide-react"; +import { SetNavBarTitleContext } from "contexts/SetNavbarTitleContext"; + +import type { Campaign, Organisation, Role } from "types/api"; + +const Section = tw.div`flex flex-col gap-3`; +// const SideCard = tw(Card)`sticky top-20`; +const Hero = tw.div`relative w-full h-64 md:h-80 rounded-xl overflow-hidden mb-8`; +const Banner = tw.img`w-full h-full object-cover`; +const HeroOverlay = tw.div`absolute inset-0 bg-gradient-to-t from-black/70 to-transparent flex items-end`; +const HeroText = tw.div`p-6 text-white`; + +const CampaignLandingPage = () => { + const { organisationSlug = "", campaignSlug = "" } = useParams(); + const [campaign, setCampaign] = useState(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const [organisation, setOrganisation] = useState(); + const [roles, setRoles] = useState([]); + const setNavBarTitle = useContext(SetNavBarTitleContext); + + useEffect(() => { + setNavBarTitle(""); + }, []); + + useEffect(() => { + let isMounted = true; + setLoading(true); + setError(undefined); + (async () => { + try { + const c = await getCampaignByOrgAndCampaignSlugs( + organisationSlug, + campaignSlug + ); + if (!isMounted) return; + setCampaign(c); + setNavBarTitle(c.name); + + // Fire off parallel requests not strictly needed for page render + const [org, rs] = await Promise.all([ + getOrganisationBySlug(organisationSlug).catch(() => undefined), + getCampaignRoles(c.id).catch(() => [] as Role[]), + ]); + if (!isMounted) return; + setOrganisation(org); + setRoles(rs); + } catch (e) { + if (!isMounted) return; + setError("Failed to load campaign"); + console.error(e); + } finally { + if (!isMounted) return; + setLoading(false); + } + })(); + + return () => { + isMounted = false; + }; + }, [organisationSlug, campaignSlug]); + + const statusBadge = useMemo(() => { + if (!campaign) return null; + const now = new Date(); + const end = new Date(campaign.ends_at); + const isOpen = now <= end; + return ( + + {isOpen ? "OPEN" : "CLOSED"} + + ); + }, [campaign]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || !campaign) { + return ( + + +

{error ?? "Campaign not found."}

+ + Back to dashboard + +
+
+ ); + } + + return ( + + {/* Hero Banner */} + + + + +
{statusBadge}
+

{campaign.name}

+

{campaign.organisation_name}

+
+
+
+ + {/* Content Grid */} +
+
+ {/* About */} + +
+ {`${campaign.organisation_name} +
+

{campaign.organisation_name}

+

{campaign.description}

+
+
+ + {/* Long description not provided by API; reuse description for now */} +
+

{campaign.description}

+
+
+ + {/* Roles */} + +
+

Available Roles

+ {roles.length === 0 ? ( +

Roles listing will appear here.

+ ) : ( +
+ {roles.map((r) => ( +
+

{r.name}

+ {r.description && ( +

{r.description}

+ )} +

+ Positions available: {r.min_available} - {r.max_available} +

+
+ ))} +
+ )} +
+
+
+ +
+ {/* Application CTA */} + +

Apply Now

+
+
+ +
+

Application Deadline

+

{new Date(campaign.ends_at).toLocaleDateString()}

+
+
+
+ +
+

Current Applicants

+

+
+
+
+ +
+ + {/* Timeline - placeholder content */} + +

Recruitment Timeline

+
    + {[ + { label: "Applications Open", date: new Date(campaign.starts_at).toLocaleDateString() }, + { label: "Applications Close", date: new Date(campaign.ends_at).toLocaleDateString() }, + ].map((item) => ( +
  1. + + + +

    {item.label}

    +

    {item.date}

    +
  2. + ))} +
+
+ + {/* Contact Info - placeholder */} + +

Contact Information

+
+
+ +

UNSW Sydney, Kensington Campus

+
+ +
+
+
+
+
+ ); +}; + +export default CampaignLandingPage; diff --git a/frontend/src/pages/dashboard/CampaignGrid/index.tsx b/frontend/src/pages/dashboard/CampaignGrid/index.tsx index 7ab6b3ad..95fe998d 100644 --- a/frontend/src/pages/dashboard/CampaignGrid/index.tsx +++ b/frontend/src/pages/dashboard/CampaignGrid/index.tsx @@ -63,6 +63,8 @@ const CampaignGrid = ({ > import("./pages/signup")); const QuestionComponentsTest = lazy(() => import("./pages/question_components_test")); const AdminApplicationDashboard = lazy(() => import("./pages/admin_application_dashboard")); const ApplicationReviewTest = lazy(() => import("./pages/application_review")) +const CampaignLandingPage = lazy(() => import("./pages/campaign")); const routes = [ } />, @@ -33,7 +34,6 @@ const routes = [ path=":roleSlug/finalise" element={} /> - , , } />, } />, + } + />, Date: Wed, 13 Aug 2025 23:28:15 +1000 Subject: [PATCH 04/46] apply page --- frontend/src/pages/application_page/index.tsx | 66 +++++++------------ frontend/src/routes.tsx | 2 +- 2 files changed, 24 insertions(+), 44 deletions(-) diff --git a/frontend/src/pages/application_page/index.tsx b/frontend/src/pages/application_page/index.tsx index bf085dea..506e71fd 100644 --- a/frontend/src/pages/application_page/index.tsx +++ b/frontend/src/pages/application_page/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import "twin.macro"; import Container from "components/Container"; @@ -11,8 +11,8 @@ import { newApplication, submitAnswer, getCampaignRoles, - getOrganisationBySlug, - getCampaignBySlugs, + getCampaign, + getOrganisation, } from "../../api"; import ApplicationForm from "./ApplicationForm"; @@ -26,27 +26,10 @@ import { NewApplication, Role, User, type Campaign, type Organisation } from "ty const ApplicationPage = () => { const navigate = useNavigate(); - const [campaign, setCampaign] = useState({ - id: -1, - organisation_name: "", - organisation_slug: "", - organisation_id: -1, - name: "", - cover_image: "", - description: "", - starts_at: "", - ends_at: "", - campaign_slug: "" - }); - const [organisation, setOrganisation] = useState({ - id: -1, - name: "", - slug: "", - logo: "", - created_at: "", - }); + const [campaign, setCampaign] = useState(null); + const [organisation, setOrganisation] = useState(null); - const { organisationSlug, campaignSlug } = useParams(); + const { campaignId } = useParams(); //const { state } = useLocation() as { state: CampaignWithRoles }; const [loading, setLoading] = useState(true); @@ -69,27 +52,23 @@ const ApplicationPage = () => { const getData = async () => { setSelfInfo(await getSelfInfo()); - //if (state) { - // setCampaign(state); - //} else { - - if(organisationSlug && campaignSlug) { - const cmpn = await getCampaignBySlugs(organisationSlug, campaignSlug); + if (campaignId) { + const id = Number(campaignId); + const cmpn = await getCampaign(id); setCampaign(cmpn); - const org = await getOrganisationBySlug(organisationSlug); + const org = await getOrganisation(cmpn.organisation_id); setOrganisation(org); - const campaignId = cmpn.id; - const commonQuestions = await getCommonQuestions(campaignId); - const commonQuestionsSimple: RoleQuestion[] = commonQuestions.questions.map((question) => { + const commonQuestions = await getCommonQuestions(id); + const commonQuestionsSimple: RoleQuestion[] = commonQuestions.map((question) => { return { id: question.id, text: question.title, } }); - const campaignRoles = await getCampaignRoles(campaignId); + const campaignRoles = await getCampaignRoles(id); setRoles(campaignRoles); // initialise roleQuestions to include common questions const roleQuestions: RoleQuestions = Object.fromEntries( @@ -99,7 +78,7 @@ const ApplicationPage = () => { await Promise.all(campaignRoles.map( async ({id: roleId}) => { // for each roleId, pushes every question to the rolearray - const questionsByRole = await getRoleQuestions(campaignId, roleId); + const questionsByRole = await getRoleQuestions(id, roleId); const questions = questionsByRole.map((questions) => { return { id: questions.id, @@ -109,11 +88,9 @@ const ApplicationPage = () => { roleQuestions[roleId].push(...questions); })); setRoleQuestions(roleQuestions); - - } else { return false; - } + } setLoading(false); }; @@ -185,6 +162,7 @@ const ApplicationPage = () => { }}), }; + if (!campaign) return; newApplication(campaign.id, newApp) .then(async (application) => { await Promise.all( @@ -218,14 +196,16 @@ const ApplicationPage = () => { // .catch(() => alert("Error during submission")); }; + if (!campaign || !organisation) return ; + return ( diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 604acac8..4d342318 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -43,7 +43,7 @@ const routes = [ />, } />, Date: Thu, 14 Aug 2025 00:15:27 +1000 Subject: [PATCH 05/46] Serialize i64 IDs as strings --- backend/server/src/models/answer.rs | 6 ++ backend/server/src/models/application.rs | 12 ++++ backend/server/src/models/auth.rs | 2 + backend/server/src/models/campaign.rs | 5 ++ backend/server/src/models/email_template.rs | 2 + backend/server/src/models/mod.rs | 1 + backend/server/src/models/offer.rs | 11 +++ backend/server/src/models/organisation.rs | 4 ++ backend/server/src/models/question.rs | 3 + backend/server/src/models/rating.rs | 5 ++ backend/server/src/models/role.rs | 4 ++ backend/server/src/models/serde_string.rs | 20 ++++++ backend/server/src/models/user.rs | 2 + frontend/src/api/index.ts | 32 ++++----- .../src/components/CampaignCard/Content.tsx | 4 +- .../src/components/CampaignCard/index.tsx | 4 +- frontend/src/components/CampaignCard/types.ts | 2 +- .../QuestionComponents/Dropdown/index.tsx | 4 +- .../QuestionComponents/MultiChoice/index.tsx | 4 +- .../QuestionComponents/MultiSelect/index.tsx | 4 +- .../QuestionComponents/Ranking/index.tsx | 4 +- .../QuestionComponents/ShortAnswer/index.tsx | 4 +- .../components/QuestionComponents/index.tsx | 2 +- frontend/src/components/Tabs.tsx | 2 +- frontend/src/contexts/MessagePopupContext.ts | 2 +- .../AdminContent/AdminMembersContent.tsx | 4 +- .../src/pages/admin/AdminContent/index.tsx | 4 +- .../src/pages/admin/review/RolesSidebar.tsx | 2 +- .../review/finalise_candidates/index.tsx | 10 +-- frontend/src/pages/admin/review/index.tsx | 5 +- .../src/pages/admin/review/marking/index.tsx | 4 +- .../FinalRatingApplicationComments/index.tsx | 2 +- .../src/pages/admin/review/rankings/index.tsx | 6 +- .../src/pages/admin/review/rankings/types.ts | 4 +- frontend/src/pages/admin/types.ts | 8 +-- .../application_page/ApplicationForm.tsx | 6 +- .../pages/application_page/RolesSidebar.tsx | 4 +- frontend/src/pages/application_page/index.tsx | 22 +++--- frontend/src/pages/application_page/types.ts | 4 +- .../src/pages/application_review/index.tsx | 36 +++++----- .../pages/create_campaign/Roles/Question.tsx | 2 +- .../Roles/SelectFromExistingMenu.tsx | 2 +- .../src/pages/create_campaign/Roles/index.tsx | 2 +- frontend/src/pages/create_campaign/index.tsx | 8 +-- .../pages/dashboard/CampaignGrid/index.tsx | 6 +- .../pages/question_components_test/index.tsx | 24 +++---- frontend/src/types/api.ts | 70 +++++++++---------- 47 files changed, 227 insertions(+), 153 deletions(-) create mode 100644 backend/server/src/models/serde_string.rs diff --git a/backend/server/src/models/answer.rs b/backend/server/src/models/answer.rs index 0ab1f97a..499d1461 100644 --- a/backend/server/src/models/answer.rs +++ b/backend/server/src/models/answer.rs @@ -32,8 +32,10 @@ use std::ops::DerefMut; #[derive(Deserialize, Serialize)] pub struct Answer { /// Unique identifier for the answer + #[serde(serialize_with = "crate::models::serde_string::serialize")] id: i64, /// ID of the question this answer is for + #[serde(serialize_with = "crate::models::serde_string::serialize")] question_id: i64, /// The actual answer data, flattened in serialization @@ -437,12 +439,16 @@ pub enum AnswerData { /// Text answer for short answer questions ShortAnswer(String), /// Single selected option for multiple choice questions + #[serde(serialize_with = "crate::models::serde_string::serialize")] MultiChoice(i64), /// Multiple selected options for multi-select questions + #[serde(serialize_with = "crate::models::serde_string::serialize_vec")] MultiSelect(Vec), /// Single selected option for dropdown questions + #[serde(serialize_with = "crate::models::serde_string::serialize")] DropDown(i64), /// Ranked list of options for ranking questions + #[serde(serialize_with = "crate::models::serde_string::serialize_vec")] Ranking(Vec), } diff --git a/backend/server/src/models/application.rs b/backend/server/src/models/application.rs index f2023099..5b6b4f91 100644 --- a/backend/server/src/models/application.rs +++ b/backend/server/src/models/application.rs @@ -27,10 +27,13 @@ use crate::service::application::{assert_application_is_open}; #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct Application { /// Unique identifier for the application + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// ID of the campaign this application belongs to + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub campaign_id: i64, /// ID of the user who submitted the application + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub user_id: i64, /// Public status of the application pub status: ApplicationStatus, @@ -50,10 +53,13 @@ pub struct Application { #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct ApplicationRole { /// Unique identifier for the role application + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// ID of the parent application + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub application_id: i64, /// ID of the campaign role being applied for + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub campaign_role_id: i64, /// User's preference ranking for this role (lower number = higher preference) pub preference: i32, @@ -75,8 +81,10 @@ pub struct NewApplication { #[derive(Deserialize, Serialize)] pub struct ApplicationDetails { /// Unique identifier for the application + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// ID of the campaign this application belongs to + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub campaign_id: i64, /// Details of the user who submitted the application pub user: UserDetails, @@ -95,10 +103,13 @@ pub struct ApplicationDetails { #[derive(Deserialize, Serialize)] pub struct ApplicationData { /// Unique identifier for the application + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// ID of the campaign this application belongs to + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub campaign_id: i64, /// ID of the user who submitted the application + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub user_id: i64, /// Email address of the applicant pub user_email: String, @@ -126,6 +137,7 @@ pub struct ApplicationData { #[derive(Deserialize, Serialize)] pub struct ApplicationAppliedRoleDetails { /// ID of the campaign role + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub campaign_role_id: i64, /// Name of the role pub role_name: String, diff --git a/backend/server/src/models/auth.rs b/backend/server/src/models/auth.rs index 16a44e45..7520f792 100644 --- a/backend/server/src/models/auth.rs +++ b/backend/server/src/models/auth.rs @@ -64,6 +64,7 @@ impl IntoResponse for AuthRedirect { #[derive(Deserialize, Serialize)] pub struct AuthUser { /// ID of the authenticated user + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub user_id: i64, } @@ -93,6 +94,7 @@ where #[derive(Deserialize, Serialize)] pub struct SuperUser { /// ID of the super user + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub user_id: i64, } diff --git a/backend/server/src/models/campaign.rs b/backend/server/src/models/campaign.rs index c6ad8189..4c79c80e 100644 --- a/backend/server/src/models/campaign.rs +++ b/backend/server/src/models/campaign.rs @@ -26,12 +26,14 @@ use super::{error::ChaosError, storage::Storage}; #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct Campaign { /// Unique identifier for the campaign + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// URL-friendly identifier for the campaign pub slug: String, /// Display name of the campaign pub name: String, /// ID of the organization running the campaign + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub organisation_id: i64, /// URL-friendly identifier for the organization pub organisation_slug: String, @@ -57,12 +59,14 @@ pub struct Campaign { #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct CampaignDetails { /// Unique identifier for the campaign + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// URL-friendly identifier for the campaign pub campaign_slug: String, /// Display name of the campaign pub name: String, /// ID of the organization running the campaign + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub organisation_id: i64, /// URL-friendly identifier for the organization pub organisation_slug: String, @@ -85,6 +89,7 @@ pub struct CampaignDetails { #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct OrganisationCampaign { /// Unique identifier for the campaign + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// URL-friendly identifier for the campaign pub slug: String, diff --git a/backend/server/src/models/email_template.rs b/backend/server/src/models/email_template.rs index 2a833526..c7c2cef1 100644 --- a/backend/server/src/models/email_template.rs +++ b/backend/server/src/models/email_template.rs @@ -23,8 +23,10 @@ use std::ops::DerefMut; #[derive(Deserialize, Serialize)] pub struct EmailTemplate { /// Unique identifier for the template + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// ID of the organisation that owns this template + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub organisation_id: i64, /// Display name of the template pub name: String, diff --git a/backend/server/src/models/mod.rs b/backend/server/src/models/mod.rs index bdb80789..fd198b65 100644 --- a/backend/server/src/models/mod.rs +++ b/backend/server/src/models/mod.rs @@ -12,6 +12,7 @@ pub mod app; pub mod application; pub mod auth; pub mod campaign; +pub mod serde_string; pub mod email; pub mod email_template; pub mod error; diff --git a/backend/server/src/models/offer.rs b/backend/server/src/models/offer.rs index bd4a70f9..c1080ecc 100644 --- a/backend/server/src/models/offer.rs +++ b/backend/server/src/models/offer.rs @@ -19,14 +19,19 @@ use std::ops::DerefMut; #[derive(Deserialize)] pub struct Offer { /// Unique identifier for the offer + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// ID of the campaign this offer belongs to + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub campaign_id: i64, /// ID of the application this offer is for + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub application_id: i64, /// ID of the email template to use for notifications + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub email_template_id: i64, /// ID of the role being offered + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub role_id: i64, /// When the offer expires pub expiry: DateTime, @@ -43,24 +48,30 @@ pub struct Offer { #[derive(Deserialize, Serialize)] pub struct OfferDetails { /// Unique identifier for the offer + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// ID of the campaign this offer belongs to + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub campaign_id: i64, /// Name of the organisation making the offer pub organisation_name: String, /// Name of the campaign pub campaign_name: String, /// ID of the application this offer is for + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub application_id: i64, /// ID of the user receiving the offer + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub user_id: i64, /// Name of the user receiving the offer pub user_name: String, /// Email address of the user receiving the offer pub user_email: String, /// ID of the email template to use for notifications + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub email_template_id: i64, /// ID of the role being offered + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub role_id: i64, /// Name of the role being offered pub role_name: String, diff --git a/backend/server/src/models/organisation.rs b/backend/server/src/models/organisation.rs index 4b1fe025..64324c0b 100644 --- a/backend/server/src/models/organisation.rs +++ b/backend/server/src/models/organisation.rs @@ -21,6 +21,7 @@ use uuid::Uuid; #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct Organisation { /// Unique identifier for the organisation + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// URL-friendly identifier for the organisation pub slug: String, @@ -35,6 +36,7 @@ pub struct Organisation { /// List of campaigns run by this organisation pub campaigns: Vec, // Awaiting Campaign to be complete - remove comment once done /// List of user IDs who are administrators of this organisation + #[serde(serialize_with = "crate::models::serde_string::serialize_vec")] pub organisation_admins: Vec, } @@ -59,6 +61,7 @@ pub struct NewOrganisation { #[derive(Deserialize, Serialize)] pub struct OrganisationDetails { /// Unique identifier for the organisation + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// URL-friendly identifier for the organisation pub slug: String, @@ -90,6 +93,7 @@ pub enum OrganisationRole { #[derive(Deserialize, Serialize, FromRow)] pub struct Member { /// ID of the user + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// Name of the user pub name: String, diff --git a/backend/server/src/models/question.rs b/backend/server/src/models/question.rs index 28c15a80..1fccb853 100644 --- a/backend/server/src/models/question.rs +++ b/backend/server/src/models/question.rs @@ -42,10 +42,12 @@ use sqlx::types::Json; /// ``` #[derive(Serialize)] pub struct Question { + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, pub title: String, pub description: Option, pub common: bool, // Common question are shown at the start + #[serde(serialize_with = "crate::models::serde_string::serialize_vec")] pub roles: Vec, // (Possibly empty) list of roles the question is for pub required: bool, @@ -525,6 +527,7 @@ pub struct MultiOptionData { /// language?", there would be rows for "Rust", "Java" and "TypeScript". #[derive(Deserialize, Serialize)] pub struct MultiOptionQuestionOption { + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, pub display_order: i32, pub text: String, diff --git a/backend/server/src/models/rating.rs b/backend/server/src/models/rating.rs index a6d634af..8bec20b0 100644 --- a/backend/server/src/models/rating.rs +++ b/backend/server/src/models/rating.rs @@ -17,10 +17,13 @@ use std::ops::DerefMut; #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct Rating { /// Unique identifier for the rating + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// ID of the application being rated + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub application_id: i64, /// ID of the user who created the rating + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub rater_user_id: i64, /// Numerical rating value pub rating: i32, @@ -51,8 +54,10 @@ pub struct NewRating { #[derive(Deserialize, Serialize)] pub struct RatingDetails { /// Unique identifier for the rating + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// ID of the user who created the rating + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub rater_id: i64, /// Name of the user who created the rating pub rater_name: String, diff --git a/backend/server/src/models/role.rs b/backend/server/src/models/role.rs index 59b11653..488c9ecb 100644 --- a/backend/server/src/models/role.rs +++ b/backend/server/src/models/role.rs @@ -17,8 +17,10 @@ use std::ops::DerefMut; #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct Role { /// Unique identifier for the role + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// ID of the campaign this role belongs to + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub campaign_id: i64, /// Optional name of the role pub name: Option, @@ -61,8 +63,10 @@ pub struct RoleUpdate { #[derive(Deserialize, Serialize)] pub struct RoleDetails { /// Unique identifier for the role + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// ID of the campaign this role belongs to + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub campaign_id: i64, /// Name of the role pub name: String, diff --git a/backend/server/src/models/serde_string.rs b/backend/server/src/models/serde_string.rs new file mode 100644 index 00000000..20784cbb --- /dev/null +++ b/backend/server/src/models/serde_string.rs @@ -0,0 +1,20 @@ +use serde::ser::{SerializeSeq, Serializer}; + +pub fn serialize(value: &i64, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +pub fn serialize_vec(values: &Vec, serializer: S) -> Result +where + S: Serializer, +{ + let mut seq = serializer.serialize_seq(Some(values.len()))?; + for v in values.iter() { + seq.serialize_element(&v.to_string())?; + } + seq.end() +} + diff --git a/backend/server/src/models/user.rs b/backend/server/src/models/user.rs index f38ad197..6829dc8e 100644 --- a/backend/server/src/models/user.rs +++ b/backend/server/src/models/user.rs @@ -27,6 +27,7 @@ pub enum UserRole { #[derive(Deserialize, Serialize, FromRow)] pub struct UserDetails { /// Unique identifier for the user + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// User's email address pub email: String, @@ -50,6 +51,7 @@ pub struct UserDetails { #[derive(Deserialize, Serialize, FromRow)] pub struct User { /// Unique identifier for the user + #[serde(serialize_with = "crate::models::serde_string::serialize")] pub id: i64, /// User's email address pub email: String, diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6e6faff7..11aa48db 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -147,7 +147,7 @@ export const putOrgLogo = async (orgId: string, logo: File) => { }); } -export const newApplication = (campaignId: number, newApp: NewApplication) => +export const newApplication = (campaignId: string, newApp: NewApplication) => authenticatedRequest({ method: "POST", path: `/v1/campaign/${campaignId}/application/`, @@ -161,7 +161,7 @@ export const doDeleteOrg = (orgId: string) => jsonResp: false, }); -export const getCampaign = (campaignId: number) => +export const getCampaign = (campaignId: string) => authenticatedRequest({ path: `/v1/campaign/${campaignId}` }); export const getCampaignBySlugs = (organisationSlug: string, campaignSlug: string) => @@ -178,28 +178,28 @@ export const getCampaignByOrgAndCampaignSlugs = ( path: `/v1/organisation/slug/${organisationSlug}/campaign/slug/${campaignSlug}`, }); -export const getCampaignRoles = (campaignId: number) => +export const getCampaignRoles = (campaignId: string) => authenticatedRequest({ path: `/v1/campaign/${campaignId}/roles`, }); -export const getRoleApplications = (roleId: number) => +export const getRoleApplications = (roleId: string) => authenticatedRequest({ path: `/v1/role/${roleId}/applications`, }); -export const getRoleQuestions = (campaignId: number, roleId: number) => +export const getRoleQuestions = (campaignId: string, roleId: string) => authenticatedRequest({ path: `/v1/campaign/${campaignId}/role/${roleId}/questions`, }); // todo: update all referencing components -export const getCommonQuestions = (campaignID: number) => +export const getCommonQuestions = (campaignID: string) => authenticatedRequest({ path: `/v1/campaign/${campaignID}/questions/common` }); -export const setApplicationRating = (applicationId: number, rating: NewRating) => +export const setApplicationRating = (applicationId: string, rating: NewRating) => authenticatedRequest({ method: "PUT", path: `/v1/${applicationId}/rating`, @@ -212,25 +212,25 @@ export const setApplicationRating = (applicationId: number, rating: NewRating) = export const getSelfInfo = () => authenticatedRequest({ path: "/v1/user" }); -export const getApplicationAnswers = (applicationId: number, roleId: number) => +export const getApplicationAnswers = (applicationId: string, roleId: string) => authenticatedRequest({ path: `/v1/application/${applicationId}/answers/role/${roleId}`, }); -export const getCommonApplicationAnswers = (applicationId: number) => +export const getCommonApplicationAnswers = (applicationId: string) => authenticatedRequest({ path: `/v1/application/${applicationId}/answers/common`, }); -export const getApplicationRatings = (applicationId: number) => +export const getApplicationRatings = (applicationId: string) => authenticatedRequest<{ ratings: ApplicationRating[] }>({ path: `/v1/${applicationId}/ratings`, }); // todo: update all referencing components export const submitAnswer = ( - applicationId: number, - questionId: number, + applicationId: string, + questionId: string, answerData: AnswerData ) => authenticatedRequest({ @@ -257,7 +257,7 @@ export const createCampaign = ( }); // todo: update to new route -export const setCampaignCoverImage = (campaignId: number, cover_image: File) => +export const setCampaignCoverImage = (campaignId: string, cover_image: File) => authenticatedRequest({ method: "PATCH", path: `/v1/campaign/${campaignId}/banner`, @@ -266,7 +266,7 @@ export const setCampaignCoverImage = (campaignId: number, cover_image: File) => }); // todo: update to new route -export const deleteCampaign = (id: number) => +export const deleteCampaign = (id: string) => authenticatedRequest({ method: "DELETE", path: `/v1/campaign/${id}`, @@ -275,7 +275,7 @@ export const deleteCampaign = (id: number) => // todo: update to new route export const setApplicationStatus = ( - applicationId: number, + applicationId: string, status: ApplicationStatus ) => authenticatedRequest({ @@ -315,7 +315,7 @@ export const inviteUserToOrg = ( * @param roleId * @returns string[][][] or string[][] */ -export const getAnsweredApplicationQuestions = (applications: ApplicationDetails[], campaignId: number, roleId: number) => { +export const getAnsweredApplicationQuestions = (applications: ApplicationDetails[], campaignId: string, roleId: string) => { return Promise.all( applications.map(async (application) => { const roleAnswers = await getApplicationAnswers(application.id, roleId); diff --git a/frontend/src/components/CampaignCard/Content.tsx b/frontend/src/components/CampaignCard/Content.tsx index 58812cf5..ff67203c 100644 --- a/frontend/src/components/CampaignCard/Content.tsx +++ b/frontend/src/components/CampaignCard/Content.tsx @@ -23,12 +23,12 @@ import type { CampaignWithRoles } from "types/api"; const dateToString = (date: Date) => moment(date).format("D MMM YYYY"); type AdminProps = { - campaignId: number; + campaignId: string; isAdmin: true; }; type NonAdminProps = { - campaignId?: number; + campaignId?: string; isAdmin?: false; }; diff --git a/frontend/src/components/CampaignCard/index.tsx b/frontend/src/components/CampaignCard/index.tsx index 5cd98bd1..01e32455 100644 --- a/frontend/src/components/CampaignCard/index.tsx +++ b/frontend/src/components/CampaignCard/index.tsx @@ -12,13 +12,13 @@ import type { Dispatch, MouseEvent, SetStateAction } from "react"; import type { CampaignWithRoles } from "types/api"; type AdminProps = { - campaignId: number; + campaignId: string; campaignSlug: string; isAdmin: true; }; type NonAdminProps = { - campaignId?: number; + campaignId?: string; campaignSlug?: string; isAdmin?: false; }; diff --git a/frontend/src/components/CampaignCard/types.ts b/frontend/src/components/CampaignCard/types.ts index 4ff157ba..e19cc431 100644 --- a/frontend/src/components/CampaignCard/types.ts +++ b/frontend/src/components/CampaignCard/types.ts @@ -1,5 +1,5 @@ export type Position = { - id: number | string; + id: string; name: string; number: number; }; diff --git a/frontend/src/components/QuestionComponents/Dropdown/index.tsx b/frontend/src/components/QuestionComponents/Dropdown/index.tsx index 556b8a96..5efc515d 100644 --- a/frontend/src/components/QuestionComponents/Dropdown/index.tsx +++ b/frontend/src/components/QuestionComponents/Dropdown/index.tsx @@ -15,14 +15,14 @@ interface DropdownOption { } interface DropdownProps { - id: number; + id: string; question: string; description?: string; options: DropdownOption[]; required?: boolean; defaultValue?: string | number; onChange?: (value: string | number) => void; - onSubmit?: (questionId: number, value: string | number) => void; + onSubmit?: (questionId: string, value: string | number) => void; disabled?: boolean; placeholder?: string; width?: string; diff --git a/frontend/src/components/QuestionComponents/MultiChoice/index.tsx b/frontend/src/components/QuestionComponents/MultiChoice/index.tsx index add1317d..22fa43ed 100644 --- a/frontend/src/components/QuestionComponents/MultiChoice/index.tsx +++ b/frontend/src/components/QuestionComponents/MultiChoice/index.tsx @@ -9,14 +9,14 @@ interface Option { } interface MultiChoiceProps { - id: number; + id: string; question: string; description?: string; options: Option[]; required?: boolean; defaultValue?: string | number; onChange?: (value: string | number) => void; - onSubmit?: (questionId: number, value: string | number) => void; + onSubmit?: (questionId: string, value: string | number) => void; disabled?: boolean; } diff --git a/frontend/src/components/QuestionComponents/MultiSelect/index.tsx b/frontend/src/components/QuestionComponents/MultiSelect/index.tsx index 50b6b88e..30420d9a 100644 --- a/frontend/src/components/QuestionComponents/MultiSelect/index.tsx +++ b/frontend/src/components/QuestionComponents/MultiSelect/index.tsx @@ -8,14 +8,14 @@ interface Option { } interface MultiSelectProps { - id: number; + id: string; question: string; description?: string; options: Option[]; required?: boolean; defaultValue?: Array; onChange?: (value: Array) => void; - onSubmit?: (questionId: number, value: Array) => void; + onSubmit?: (questionId: string, value: Array) => void; disabled?: boolean; } diff --git a/frontend/src/components/QuestionComponents/Ranking/index.tsx b/frontend/src/components/QuestionComponents/Ranking/index.tsx index ca05f05e..96199c23 100644 --- a/frontend/src/components/QuestionComponents/Ranking/index.tsx +++ b/frontend/src/components/QuestionComponents/Ranking/index.tsx @@ -12,14 +12,14 @@ interface RankedOption extends Option { } interface RankingProps { - id: number; + id: string; question: string; description?: string; options: Option[]; required?: boolean; defaultValue?: Array; onChange?: (value: Array) => void; - onSubmit?: (questionId: number, value: Array) => void; + onSubmit?: (questionId: string, value: Array) => void; disabled?: boolean; width?: string; height?: string; diff --git a/frontend/src/components/QuestionComponents/ShortAnswer/index.tsx b/frontend/src/components/QuestionComponents/ShortAnswer/index.tsx index 0ea8679f..820d4a59 100644 --- a/frontend/src/components/QuestionComponents/ShortAnswer/index.tsx +++ b/frontend/src/components/QuestionComponents/ShortAnswer/index.tsx @@ -4,13 +4,13 @@ import { Textarea } from '@/components/ui/textarea'; import tw from 'twin.macro'; interface ShortAnswerProps { - id: number; + id: string; question: string; description?: string; required?: boolean; defaultValue?: string; onChange?: (value: string) => void; - onSubmit?: (questionId: number, value: string) => void; + onSubmit?: (questionId: string, value: string) => void; disabled?: boolean; rows?: number; placeholder?: string; diff --git a/frontend/src/components/QuestionComponents/index.tsx b/frontend/src/components/QuestionComponents/index.tsx index 77faeeff..efc59290 100644 --- a/frontend/src/components/QuestionComponents/index.tsx +++ b/frontend/src/components/QuestionComponents/index.tsx @@ -5,7 +5,7 @@ export type QuestionType = 'short_answer' | 'dropdown' | 'multi_choice' | 'multi // Export common interfaces export interface BaseQuestionProps { - id: number; + id: string; question: string; description?: string; required?: boolean; diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx index e7bcee15..d1d29cea 100644 --- a/frontend/src/components/Tabs.tsx +++ b/frontend/src/components/Tabs.tsx @@ -30,7 +30,7 @@ const TabButton = styled("button", { }); type Props = ComponentProps & { - tabs: { id: number; contents: ReactNode }[]; + tabs: { id: string; contents: ReactNode }[]; }; const Tabs = ({ tabs, vertical, ...props }: Props) => ( diff --git a/frontend/src/contexts/MessagePopupContext.ts b/frontend/src/contexts/MessagePopupContext.ts index 8e7c067f..cdac97e0 100644 --- a/frontend/src/contexts/MessagePopupContext.ts +++ b/frontend/src/contexts/MessagePopupContext.ts @@ -3,7 +3,7 @@ import { createContext } from "react"; export type Message = { type: "error" | "warning" | "success"; message: string; - id: number; + id: string; }; // eslint-disable-next-line @typescript-eslint/no-empty-function diff --git a/frontend/src/pages/admin/AdminContent/AdminMembersContent.tsx b/frontend/src/pages/admin/AdminContent/AdminMembersContent.tsx index 101a703c..92b028fb 100644 --- a/frontend/src/pages/admin/AdminContent/AdminMembersContent.tsx +++ b/frontend/src/pages/admin/AdminContent/AdminMembersContent.tsx @@ -31,7 +31,7 @@ import type { ChangeEventHandler, Dispatch, SetStateAction } from "react"; import type { AdminLevel } from "types/api"; type Props = { - orgId: number; + orgId: string; members: Member[]; setMembers: Dispatch>; }; @@ -40,7 +40,7 @@ const AdminMembersContent = ({ orgId, members, setMembers }: Props) => { const [anchorEl, setAnchorEl] = useState(null); const onDelete = (memberId: string) => { // FIXME: CHAOS-55, integrate with backend to actually delete - setMembers(members.filter((m) => m.id !== Number(memberId))); + setMembers(members.filter((m) => m.id !== memberId)); }; const inviteUser = (formValues: { email: string; diff --git a/frontend/src/pages/admin/AdminContent/index.tsx b/frontend/src/pages/admin/AdminContent/index.tsx index 5b9fe6c8..00f1b3bb 100644 --- a/frontend/src/pages/admin/AdminContent/index.tsx +++ b/frontend/src/pages/admin/AdminContent/index.tsx @@ -45,13 +45,13 @@ const AdminContent = ({ members, setMembers, }: Props) => { - let id: number; + let id: string; let icon; let orgName: string; if (org) { ({ id, icon, orgName } = org); } else { - id = 0; + id = "0"; icon = ""; orgName = "..."; } diff --git a/frontend/src/pages/admin/review/RolesSidebar.tsx b/frontend/src/pages/admin/review/RolesSidebar.tsx index d6b22a54..b0e58d46 100644 --- a/frontend/src/pages/admin/review/RolesSidebar.tsx +++ b/frontend/src/pages/admin/review/RolesSidebar.tsx @@ -10,7 +10,7 @@ type Props = { roles: Role[]; }; const RolesSidebar = ({ roles }: Props) => { - const roleId = Number(useParams().roleId); + const roleId = String(useParams().roleId); return ( { - const campaignId = Number(useParams().campaignId); + const campaignId = String(useParams().campaignId); const setNavBarTitle = useContext(SetNavBarTitleContext); - const roleId = Number(useParams().roleId); + const roleId = String(useParams().roleId); const roles = useRoles(); const [organisation, setOrganisation] = useState("ORGANISATION"); - const [emails, setEmails] = useState<{ [id: number]: string }>({}); + const [emails, setEmails] = useState<{ [id: string]: string }>({}); const { get: getOrg, loading: orgLoading } = useFetch( `/organisation`, @@ -110,7 +110,7 @@ const FinaliseCandidates = () => { ); const renderEmail = useCallback( - (id: number, name: string) => { + (id: string, name: string) => { const emailParams = { name, ...params }; return emails[id].replaceAll( // this should be fine because we know all the param names ahead of time and none of them @@ -149,7 +149,7 @@ const FinaliseCandidates = () => { return useCallback( ( - applicationId: number, + applicationId: string, ...args: DropFirst> ) => putApplication(`/${applicationId}/status`, ...args), [putApplication] diff --git a/frontend/src/pages/admin/review/index.tsx b/frontend/src/pages/admin/review/index.tsx index ce63dd07..0206d6a0 100644 --- a/frontend/src/pages/admin/review/index.tsx +++ b/frontend/src/pages/admin/review/index.tsx @@ -17,7 +17,7 @@ import type { Role } from "types/api"; const Review = () => { const params = useParams(); - const campaignId = Number(params.campaignId); + const campaignId = String(params.campaignId); const navigate = useNavigate(); const setNavBarTitle = useContext(SetNavBarTitleContext); @@ -50,6 +50,7 @@ const Review = () => { ); }; -export const useRoles = () => useOutletContext<{ [id: number]: Role }>(); +export const useRoles = () => useOutletContext<{ [id: string]: Role }>(); +// IDs are strings now, but the outlet context type isn't used heavily. export default Review; diff --git a/frontend/src/pages/admin/review/marking/index.tsx b/frontend/src/pages/admin/review/marking/index.tsx index 3393e850..dfdd3edb 100644 --- a/frontend/src/pages/admin/review/marking/index.tsx +++ b/frontend/src/pages/admin/review/marking/index.tsx @@ -25,12 +25,12 @@ import type { ApplicationWithQuestions } from "pages/admin/types"; const Marking = () => { const setNavBarTitle = useContext(SetNavBarTitleContext); - const campaignId = Number(useParams().campaignId); + const campaignId = String(useParams().campaignId); const [loading, setLoading] = useState(true); const [applications, setApplications] = useState( [] ); - const roleId = Number(useParams().roleId); + const roleId = String(useParams().roleId); const [selectedApplication, setSelectedApplication] = useState(0); useEffect(() => { diff --git a/frontend/src/pages/admin/review/rankings/FinalRatingApplicationComments/index.tsx b/frontend/src/pages/admin/review/rankings/FinalRatingApplicationComments/index.tsx index 8a16fcde..509e803e 100644 --- a/frontend/src/pages/admin/review/rankings/FinalRatingApplicationComments/index.tsx +++ b/frontend/src/pages/admin/review/rankings/FinalRatingApplicationComments/index.tsx @@ -21,7 +21,7 @@ const FinalRatingApplicationComments = ({ application, }: Props) => { const roles = useRoles(); - const roleId = Number(useParams().roleId); + const roleId = String(useParams().roleId); return ( diff --git a/frontend/src/pages/admin/review/rankings/index.tsx b/frontend/src/pages/admin/review/rankings/index.tsx index 106ae654..a20c1f34 100644 --- a/frontend/src/pages/admin/review/rankings/index.tsx +++ b/frontend/src/pages/admin/review/rankings/index.tsx @@ -45,9 +45,9 @@ const rankingCmp = (x: Ranking, y: Ranking) => ratingsMean(y.ratings) - ratingsMean(x.ratings); const Rankings = () => { - const campaignId = Number(useParams().campaignId); + const campaignId = String(useParams().campaignId); const setNavBarTitle = useContext(SetNavBarTitleContext); - const roleId = Number(useParams().roleId); + const roleId = String(useParams().roleId); const [loading, setLoading] = useState(true); const [rankings, setRankings] = useState([]); const [applications, setApplications] = useState({}); @@ -92,7 +92,7 @@ const Rankings = () => { setNavBarTitle(`Ranking for ${campaignName}`); const applications = await getRoleApplications(roleId); - const getRatings = async (applicationId: number) => { + const getRatings = async (applicationId: string) => { const { ratings } = await getApplicationRatings(applicationId); const userIdsSeen = new Set(); return ratings diff --git a/frontend/src/pages/admin/review/rankings/types.ts b/frontend/src/pages/admin/review/rankings/types.ts index 8c1d4cdc..9958d194 100644 --- a/frontend/src/pages/admin/review/rankings/types.ts +++ b/frontend/src/pages/admin/review/rankings/types.ts @@ -3,7 +3,7 @@ import type { ApplicationStatus } from "types/api"; export type Ranking = { name: string; - id: number; + id: string; status: ApplicationStatus; ratings: { rater: string; rating: number }[]; }; @@ -13,5 +13,5 @@ export type Rankings = { }; export type Applications = { - [id: number]: ApplicationWithQuestions; + [id: string]: ApplicationWithQuestions; }; diff --git a/frontend/src/pages/admin/types.ts b/frontend/src/pages/admin/types.ts index 5d31ddcd..ddfcbdda 100644 --- a/frontend/src/pages/admin/types.ts +++ b/frontend/src/pages/admin/types.ts @@ -5,7 +5,7 @@ import type { } from "../../types/api"; export type Organisation = { - id: number; + id: string; icon: string; orgName: string; campaigns: CampaignInfo[]; @@ -13,7 +13,7 @@ export type Organisation = { }; export type Campaign = { - id: number; + id: string; image: string; title: string; startDate: string; @@ -21,7 +21,7 @@ export type Campaign = { }; export type Member = { - id: number; + id: string; name: string; role: AdminLevel; }; @@ -32,7 +32,7 @@ type Question = { }; export type ApplicationWithQuestions = { - applicationId: number; + applicationId: string; zId: string; mark?: number; questions: Question[]; diff --git a/frontend/src/pages/application_page/ApplicationForm.tsx b/frontend/src/pages/application_page/ApplicationForm.tsx index e1fb6413..ec89379f 100644 --- a/frontend/src/pages/application_page/ApplicationForm.tsx +++ b/frontend/src/pages/application_page/ApplicationForm.tsx @@ -11,10 +11,10 @@ import type { Role } from "types/api"; type Props = { roles: Role[]; - rolesSelected: number[]; + rolesSelected: string[]; roleQuestions: RoleQuestions; - answers: { [question: number]: string }; - setAnswer: (_question: number, _answer: string) => void; + answers: { [question: string]: string }; + setAnswer: (_question: string, _answer: string) => void; onSubmit: () => void; }; const ApplicationForm = ({ diff --git a/frontend/src/pages/application_page/RolesSidebar.tsx b/frontend/src/pages/application_page/RolesSidebar.tsx index 5dbe0b62..0c3cfbeb 100644 --- a/frontend/src/pages/application_page/RolesSidebar.tsx +++ b/frontend/src/pages/application_page/RolesSidebar.tsx @@ -7,8 +7,8 @@ import type { Role } from "types/api"; type Props = { roles: Role[]; - rolesSelected: number[]; - toggleRole: (_roleId: number) => void; + rolesSelected: string[]; + toggleRole: (_roleId: string) => void; }; const RolesSidebar = ({ roles, rolesSelected, toggleRole }: Props) => ( diff --git a/frontend/src/pages/application_page/index.tsx b/frontend/src/pages/application_page/index.tsx index 506e71fd..4a9f091a 100644 --- a/frontend/src/pages/application_page/index.tsx +++ b/frontend/src/pages/application_page/index.tsx @@ -35,7 +35,7 @@ const ApplicationPage = () => { const [loading, setLoading] = useState(true); const [selfInfo, setSelfInfo] = useState({ - id: -1, + id: "", email: "", zid: "", name: "", @@ -45,7 +45,7 @@ const ApplicationPage = () => { degree_starting_year: -1, }); - const [roleQuestions, setRoleQuestions] = useState({0: []}); + const [roleQuestions, setRoleQuestions] = useState({"0": []}); const [roles, setRoles] = useState([]); useEffect(() => { @@ -53,7 +53,7 @@ const ApplicationPage = () => { setSelfInfo(await getSelfInfo()); if (campaignId) { - const id = Number(campaignId); + const id = campaignId; const cmpn = await getCampaign(id); setCampaign(cmpn); @@ -101,11 +101,11 @@ const ApplicationPage = () => { } }, []); - const [rolesSelected, setRolesSelected] = useState([]); - const [answers, setAnswers] = useState<{ [question: number]: string }>({}); + const [rolesSelected, setRolesSelected] = useState([]); + const [answers, setAnswers] = useState<{ [question: string]: string }>({}); const toggleRole = useCallback( - (roleId: number) => { + (roleId: string) => { if (rolesSelected.includes(roleId)) { setRolesSelected(rolesSelected.filter((r) => r !== roleId)); } else { @@ -116,7 +116,7 @@ const ApplicationPage = () => { ); const setAnswer = useCallback( - (question: number, answer: string) => { + (question: string, answer: string) => { setAnswers({ ...answers, [question]: answer }); }, [answers] @@ -156,10 +156,9 @@ const ApplicationPage = () => { } const newApp: NewApplication = { - applied_roles: rolesSelected.map(roleId => { - return { - campaign_role_id: roleId - }}), + applied_roles: rolesSelected.map(roleId => ({ + campaign_role_id: roleId + })), }; if (!campaign) return; @@ -167,7 +166,6 @@ const ApplicationPage = () => { .then(async (application) => { await Promise.all( Object.keys(answers) - .map(Number) .filter((qId) => roleQuestions[application.role_id] .find((q) => q.id === qId) diff --git a/frontend/src/pages/application_page/types.ts b/frontend/src/pages/application_page/types.ts index 9823dbf1..04c13adf 100644 --- a/frontend/src/pages/application_page/types.ts +++ b/frontend/src/pages/application_page/types.ts @@ -1,8 +1,8 @@ export type RoleQuestions = { - [role: number]: RoleQuestion[]; + [role: string]: RoleQuestion[]; }; export type RoleQuestion = { - id: number; + id: string; text: string; } \ No newline at end of file diff --git a/frontend/src/pages/application_review/index.tsx b/frontend/src/pages/application_review/index.tsx index 6dd6b1f3..e3307108 100644 --- a/frontend/src/pages/application_review/index.tsx +++ b/frontend/src/pages/application_review/index.tsx @@ -40,7 +40,7 @@ import Ranking from "components/QuestionComponents/Ranking"; // Types for API responses interface ApiRole { - id: number; + id: string; name: string; description: string; min_available: number; @@ -49,7 +49,7 @@ interface ApiRole { } interface ApiCampaign { - id: number; + id: string; name: string; description?: string; starts_at: string; @@ -79,7 +79,7 @@ const DevsocRecruitmentForm: React.FC = () => { const [currentTab, setCurrentTab] = useState<'general' | 'review'>('general'); const [selectedRole, setSelectedRole] = useState(''); - const [answers, setAnswers] = useState<{ [key: number]: any }>({}); + const [answers, setAnswers] = useState<{ [key: string]: any }>({}); const [showCampaignWarning, setShowCampaignWarning] = useState(!campaignId); // New state for dynamic data @@ -230,7 +230,7 @@ const DevsocRecruitmentForm: React.FC = () => { loadData(); }, [activeCampaignId]); - const handleAnswerSubmit = (questionId: number, value: any) => { + const handleAnswerSubmit = (questionId: string, value: any) => { setAnswers((prev) => ({ ...prev, [questionId]: value, @@ -368,8 +368,8 @@ const DevsocRecruitmentForm: React.FC = () => {

Name

- { onSubmit={handleAnswerSubmit} /> { {/* Email */} { {/* zID */} { {/* Degree */} { {/* Phone Number */} { {/* Gender */}
diff --git a/frontend/src/pages/create_campaign/Roles/Question.tsx b/frontend/src/pages/create_campaign/Roles/Question.tsx index 234932d5..b3e3fc2b 100644 --- a/frontend/src/pages/create_campaign/Roles/Question.tsx +++ b/frontend/src/pages/create_campaign/Roles/Question.tsx @@ -14,7 +14,7 @@ type Props = { question: IQuestion; handleQuestionInput: ( e: ChangeEvent, - qId: number + qId: string ) => void; onQuestionDelete: MouseEventHandler; }; diff --git a/frontend/src/pages/create_campaign/Roles/SelectFromExistingMenu.tsx b/frontend/src/pages/create_campaign/Roles/SelectFromExistingMenu.tsx index cb216f2c..a64565ea 100644 --- a/frontend/src/pages/create_campaign/Roles/SelectFromExistingMenu.tsx +++ b/frontend/src/pages/create_campaign/Roles/SelectFromExistingMenu.tsx @@ -8,7 +8,7 @@ import type { MouseEventHandler } from "react"; type Props = { filteredQuestions: Question[]; - selectFromExisting: (id: number) => void; + selectFromExisting: (id: string) => void; open: boolean; handleSelectFromExistingClick: MouseEventHandler; handleCloseSelectFromExisting: MouseEventHandler; diff --git a/frontend/src/pages/create_campaign/Roles/index.tsx b/frontend/src/pages/create_campaign/Roles/index.tsx index d61e59ca..faf6e5fc 100644 --- a/frontend/src/pages/create_campaign/Roles/index.tsx +++ b/frontend/src/pages/create_campaign/Roles/index.tsx @@ -106,7 +106,7 @@ const RolesTab = ({ campaign }: Props) => { ) => { setQuestions( questions.map((q) => - q.id !== targetQID + q.id !== targetQID ? q : { id: q.id, text: e.currentTarget.value, roles: q.roles } ) diff --git a/frontend/src/pages/create_campaign/index.tsx b/frontend/src/pages/create_campaign/index.tsx index ac6e8a94..be9cd886 100644 --- a/frontend/src/pages/create_campaign/index.tsx +++ b/frontend/src/pages/create_campaign/index.tsx @@ -19,7 +19,7 @@ import { ArrowIcon, NextButton, NextWrapper } from "./createCampaign.styled"; import type { Answers, Question, Role } from "./types"; const CreateCampaign = () => { - const orgId = Number(useParams().orgId); + const orgId = String(useParams().orgId); const navigate = useNavigate(); useEffect(() => { const fetchData = async () => { @@ -114,8 +114,8 @@ const CreateCampaign = () => { return; } - const roleMap = new Map(); - const roleCheckSet = new Set(); + const roleMap = new Map(); + const roleCheckSet = new Set(); let flag = true; roles.forEach((role) => { @@ -200,7 +200,7 @@ const CreateCampaign = () => { const startTimeDateString = dateToStringForBackend(startDate); const endTimeDateString = dateToStringForBackend(endDate); const campaignSend = { - organisation_id: Number(orgId), + organisation_id: orgId, name: campaignName, description, starts_at: startTimeDateString, diff --git a/frontend/src/pages/dashboard/CampaignGrid/index.tsx b/frontend/src/pages/dashboard/CampaignGrid/index.tsx index 95fe998d..062933e5 100644 --- a/frontend/src/pages/dashboard/CampaignGrid/index.tsx +++ b/frontend/src/pages/dashboard/CampaignGrid/index.tsx @@ -9,7 +9,7 @@ import type { Campaign, Organisation } from "types/api"; type Props = { campaigns: Campaign[]; - organisations: { [orgId: number]: Organisation }; + organisations: { [orgId: string]: Organisation }; loading: boolean; loadingNumCampaigns: number; animationDelay?: number; @@ -71,9 +71,7 @@ const CampaignGrid = ({ startDate={new Date(campaign.starts_at)} endDate={new Date(campaign.ends_at)} img={campaign.cover_image} - organisationLogo={ - organisations[campaign.organisation_id]?.logo - } + organisationLogo={organisations[campaign.organisation_id]?.logo} campaigns={[]} setCampaigns={() => {}} /> diff --git a/frontend/src/pages/question_components_test/index.tsx b/frontend/src/pages/question_components_test/index.tsx index 1ca5fe3c..0f6b486f 100644 --- a/frontend/src/pages/question_components_test/index.tsx +++ b/frontend/src/pages/question_components_test/index.tsx @@ -10,10 +10,10 @@ import Ranking from "components/QuestionComponents/Ranking"; const QuestionComponentsTestPage: React.FC = () => { // State to store answers - const [answers, setAnswers] = useState<{ [key: number]: any }>({}); + const [answers, setAnswers] = useState<{ [key: string]: any }>({}); // Handler for answer submission - const handleAnswerSubmit = (questionId: number, value: any) => { + const handleAnswerSubmit = (questionId: string, value: any) => { setAnswers((prev) => ({ ...prev, [questionId]: value, @@ -39,11 +39,11 @@ const QuestionComponentsTestPage: React.FC = () => { @@ -55,12 +55,12 @@ const QuestionComponentsTestPage: React.FC = () => { @@ -72,12 +72,12 @@ const QuestionComponentsTestPage: React.FC = () => { @@ -89,12 +89,12 @@ const QuestionComponentsTestPage: React.FC = () => { @@ -106,12 +106,12 @@ const QuestionComponentsTestPage: React.FC = () => { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index c7846251..9fd35546 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -24,8 +24,8 @@ export type RoleWithDates = Role & { }; export type Role = { - id: number; - campaign_id: number; + id: string; + campaign_id: string; name: string; description?: string; min_available: number; @@ -47,8 +47,8 @@ export type RoleApplications = { // models::application::ApplicationDetails export type ApplicationDetails = { - id: number; - campaign_id: number; + id: string; + campaign_id: string; user: User; status: ApplicationStatus; private_status: ApplicationStatus; @@ -57,13 +57,13 @@ export type ApplicationDetails = { // models::application::ApplicationRoleDetails export type ApplicationAppliedRoleDetails = { - campaign_role_id: number; + campaign_role_id: string; role_name: string; } export type Question = { - id: number; - role_ids: number[]; + id: string; + role_ids: string[]; title: string; description?: string; max_bytes: number; @@ -82,7 +82,7 @@ export type NewQuestion = { // models::question::Question export type QuestionResponse = { - id: number; + id: string; title: string; description?: string; //common: boolean; @@ -105,7 +105,7 @@ export enum QuestionType { export type QuestionData = { options: { - id: number, + id: string, displayOrder: number, text: string }; @@ -131,25 +131,25 @@ export type NewApplication = { // models::application::NewApplication export type ApplicationRole = { - campaign_role_id: number, + campaign_role_id: string, } export type Application = { - id: number; - user_id: number; - role_id: number; + id: string; + user_id: string; + role_id: string; status: ApplicationStatus; }; export type ApplicationResponse = { - id: number; - user_id: number; + id: string; + user_id: string; user_email: string; user_zid: string; user_display_name: string; user_degree_name: string; user_degree_starting_year: number; - role_id: number; + role_id: string; status: ApplicationStatus; private_status: ApplicationStatus; created_at: string; @@ -158,15 +158,15 @@ export type ApplicationResponse = { // models::answer::Answer export type Answer = { - id: number, - question_id: number, + id: string, + question_id: string, answer_type: QuestionType, data: AnswerData, created_at: Date, updated_at: Date, } -export type AnswerData = string | number | number[]; +export type AnswerData = string | string[]; // export type AnswerData = // { type: QuestionType.ShortAnswer; value: string } | @@ -176,9 +176,9 @@ export type AnswerData = string | number | number[]; // { type: QuestionType.Ranking; value: number[] }; export type ApplicationAnswer = { - id: number; - application_id: number; - question_id: number; + id: string; + application_id: string; + question_id: string; description: string; created_at: string; updated_at: string; @@ -190,8 +190,8 @@ export type NewRating = { } export type ApplicationRating = { - id: number; - rater_id: number; + id: string; + rater_id: string; rater_name: string rating: number; comment?: string; @@ -200,7 +200,7 @@ export type ApplicationRating = { // models::campaign::Campaign export type CampaignWithDates = { - id: number; + id: string; slug: string organisation_id: string; // Changed to string to handle large integers name: string; @@ -214,7 +214,7 @@ export type CampaignWithDates = { // models::campaign::CampaignDetails export type Campaign = { - id: number; + id: string; slug: string; name: string; organisation_id: string; // Changed to string to handle large integers @@ -227,7 +227,7 @@ export type Campaign = { } export type CampaignInfo = { - id: number; + id: string; name: string; cover_image?: string; starts_at: string; @@ -238,7 +238,7 @@ export type CampaignWithRoles = { campaign: Campaign; roles: Role[]; questions: Question[]; - applied_for: [number, ApplicationStatus][]; // [roleId, ApplicationStatus] + applied_for: [string, ApplicationStatus][]; // [roleId, ApplicationStatus] }; export type NewCampaignInput = { @@ -256,7 +256,7 @@ export type LogoError = | "ImageStoreFailure"; export type newOrganisation = { - admin: number, + admin: string, slug: string, name: string }; @@ -270,13 +270,13 @@ export type Organisation = { }; export type Member = { - id: number; + id: string; name: string; role: OrganisationRole; }; export type OrganisationInfo = { - id: number; + id: string; name: string; logo?: string; members: Member[]; @@ -288,7 +288,7 @@ export type UserGender = "Male" | "Female" | "Unspecified"; // matches both models::user::User and models::user::UserDetails in the backend export type User = { - id: number; + id: string; email: string; zid: string; name: string; @@ -306,9 +306,9 @@ export enum UserRole { } export type PostCommentRespone = { - id: number; - application_id: number; - commenter_user_id: number; + id: string; + application_id: string; + commenter_user_id: string; description: string; created_at: string; updated_at: string; From 1b09a1022d1f83176aaec20ba178029ed1b5f0b9 Mon Sep 17 00:00:00 2001 From: Kavika Date: Thu, 14 Aug 2025 00:20:02 +1000 Subject: [PATCH 06/46] update all array_agg of json to include to_jsonb --- backend/server/src/models/question.rs | 30 ++++++++++++++++----------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/backend/server/src/models/question.rs b/backend/server/src/models/question.rs index 1fccb853..089c425c 100644 --- a/backend/server/src/models/question.rs +++ b/backend/server/src/models/question.rs @@ -219,13 +219,15 @@ impl Question { q.question_type AS "question_type: QuestionType", q.created_at, q.updated_at, - array_agg( - jsonb_build_object( + 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>" + ) ORDER BY mod.display_order + ) FILTER (WHERE mod.id IS NOT NULL) + ) AS "multi_option_data: Json>" FROM questions q LEFT JOIN @@ -285,13 +287,15 @@ impl Question { q.question_type AS "question_type: QuestionType", q.created_at, q.updated_at, - array_agg( - jsonb_build_object( + 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>" + ) ORDER BY mod.display_order + ) FILTER (WHERE mod.id IS NOT NULL) + ) AS "multi_option_data: Json>" FROM questions q JOIN @@ -351,13 +355,15 @@ impl Question { q.question_type AS "question_type: QuestionType", q.created_at, q.updated_at, - array_agg( - jsonb_build_object( + 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>" + ) ORDER BY mod.display_order + ) FILTER (WHERE mod.id IS NOT NULL) + ) AS "multi_option_data: Json>" FROM questions q LEFT JOIN From 1039f866ecb171cfb0444293b91fc9210f3c53a4 Mon Sep 17 00:00:00 2001 From: Kavika Date: Thu, 14 Aug 2025 00:51:55 +1000 Subject: [PATCH 07/46] display application questions --- .../src/pages/application_review/index.tsx | 717 ++++++------------ frontend/src/pages/campaign/index.tsx | 2 +- frontend/src/types/api.ts | 2 +- 3 files changed, 245 insertions(+), 476 deletions(-) diff --git a/frontend/src/pages/application_review/index.tsx b/frontend/src/pages/application_review/index.tsx index e3307108..cb5e4e14 100644 --- a/frontend/src/pages/application_review/index.tsx +++ b/frontend/src/pages/application_review/index.tsx @@ -1,493 +1,262 @@ -/** - * DevsocRecruitmentForm Component - * - * This component displays a dynamic recruitment form that loads campaign data and roles from the API. - * It implements a fallback system to ensure the form always works, even when the API is unavailable. - * - * HOW IT WORKS: - * 1. Extracts campaignId from URL params (e.g., /campaign/1/apply) - * 2. Falls back to campaignId "1" if no ID is provided in the URL - * 3. Attempts to fetch real data from two API endpoints: - * - GET /api/v1/campaigns/{campaignId} (for campaign details: name, dates) - * - GET /api/v1/campaigns/{campaignId}/roles (for available roles) - * 4. If API calls fail, gracefully falls back to hardcoded mock data - * 5. Shows a loading spinner while fetching data - * 6. Displays a warning banner when using fallback campaignId - * - * API INTEGRATION: - * - Based on endpoints from PR #562: https://github.com/devsoc-unsw/chaos/pull/562/files - * - Maps API response formats to structures - * - Automatically assigns colors to API roles - * - Converts ISO date strings to display format - * - * FALLBACK SYSTEM: - * - Mock campaigns with different dates and titles for testing - * - Predefined roles with descriptions and color schemes - * - Ensures form is always functional during development - * - Console logging helps identify when fallback is being used - * - * URL PATTERNS: - * - /campaign/1/apply → Real API data for campaign 1 (or fallback) - * - /campaign/2/apply → Real API data for campaign 2 (or fallback) - */ -import React, { useState, useEffect } from "react"; -import { useParams } from 'react-router-dom'; +import React, { useState, useEffect, useMemo } from "react"; +import { useParams } from "react-router-dom"; import ShortAnswer from "components/QuestionComponents/ShortAnswer"; import Dropdown from "components/QuestionComponents/Dropdown"; import MultiChoice from "components/QuestionComponents/MultiChoice"; import MultiSelect from "components/QuestionComponents/MultiSelect"; import Ranking from "components/QuestionComponents/Ranking"; +import { getCampaign, getCampaignRoles, getCommonQuestions, getRoleQuestions } from "api"; +import type { Campaign, Role, QuestionResponse, QuestionData } from "types/api"; + +const ApplicationReview: React.FC = () => { + const { campaignId } = useParams<{ campaignId: string }>(); + + const [campaign, setCampaign] = useState(null); + const [roles, setRoles] = useState([]); + const [selectedRoleIds, setSelectedRoleIds] = useState([]); + const [activeTab, setActiveTab] = useState<"general" | string>("general"); + const [commonQuestions, setCommonQuestions] = useState([]); + const [questionsByRole, setQuestionsByRole] = useState>({}); + const [answers, setAnswers] = useState>({}); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!campaignId) return; + (async () => { + setLoading(true); + try { + const idStr = campaignId; + const [c, r, common] = await Promise.all([ + getCampaign(idStr), + getCampaignRoles(idStr), + getCommonQuestions(idStr), + ]); + setCampaign(c); + setRoles(r); + const commonList = Array.isArray(common) + ? common + : (common as unknown as { questions?: QuestionResponse[] }).questions ?? []; + setCommonQuestions(commonList); + } finally { + setLoading(false); + } + })(); + }, [campaignId]); + + // Fetch role-specific questions when a new role is selected + useEffect(() => { + if (!campaignId) return; + const id = campaignId; + const missing = selectedRoleIds.filter((rid) => questionsByRole[rid] === undefined); + if (missing.length === 0) return; + (async () => { + const updates: Record = {}; + await Promise.all( + missing.map(async (rid) => { + const resp = await getRoleQuestions(id, rid); + updates[rid] = Array.isArray(resp) + ? resp + : (resp as unknown as { questions?: QuestionResponse[] }).questions ?? []; + }) + ); + setQuestionsByRole((prev) => ({ ...prev, ...updates })); + })(); + }, [selectedRoleIds, campaignId, questionsByRole]); + + const toggleRole = (roleId: string) => { + setSelectedRoleIds((prev: string[]) => + prev.includes(roleId) ? prev.filter((id) => id !== roleId) : [...prev, roleId] + ); + }; -// Types for API responses -interface ApiRole { - id: string; - name: string; - description: string; - min_available: number; - max_available: number; - finalised: boolean; -} - -interface ApiCampaign { - id: string; - name: string; - description?: string; - starts_at: string; - ends_at: string; -} - -// Local role interface for UI -interface Role { - name: string; - description: string; - color: string; -} - -// Campaign interface for UI -interface Campaign { - title: string; - startDate: string; - endDate: string; -} - -const DevsocRecruitmentForm: React.FC = () => { - // Get the campaignId from the URL parameters - const { campaignId } = useParams<{ campaignId: string }>(); - - // Fallback to a default campaign ID 1 if none is provided - const activeCampaignId = campaignId || "1"; - - const [currentTab, setCurrentTab] = useState<'general' | 'review'>('general'); - const [selectedRole, setSelectedRole] = useState(''); - const [answers, setAnswers] = useState<{ [key: string]: any }>({}); - const [showCampaignWarning, setShowCampaignWarning] = useState(!campaignId); - - // New state for dynamic data - const [campaignData, setCampaignData] = useState(null); - const [roles, setRoles] = useState([]); - const [isLoading, setIsLoading] = useState(true); - - // Fallback campaign data - const getFallbackCampaignData = (id: string): Campaign => { - const campaigns = { - "1": { - title: "2025 DevSoc Subcommittee Recruitment", - startDate: "2025-02-01", - endDate: "2025-02-20" - }, - "2": { - title: "2025 Summer Internship Program", - startDate: "2025-03-01", - endDate: "2025-03-15" - }, - "3": { - title: "2025 Winter Workshop Series", - startDate: "2025-07-01", - endDate: "2025-07-31" - } - }; - - return campaigns[id] || campaigns["1"]; - }; - - // Fallback roles data - const getFallbackRoles = (): Role[] => { - return [ - { - name: 'Marketing', - description: 'Social media, content creation, and promotional campaigns', - color: 'bg-purple-100 border-purple-300 text-purple-800' - }, - { - name: 'Events', - description: 'Workshop planning, hackathons, and networking events', - color: 'bg-blue-100 border-blue-300 text-blue-800' - }, - { - name: 'Education', - description: 'Technical workshops and mentorship programs', - color: 'bg-green-100 border-green-300 text-green-800' - }, - { - name: 'Industry', - description: 'Corporate partnerships and sponsorship management', - color: 'bg-orange-100 border-orange-300 text-orange-800' - }, - { - name: 'Design', - description: 'Visual content, branding, and user experience', - color: 'bg-pink-100 border-pink-300 text-pink-800' - }, - { - name: 'IT', - description: 'Technical infrastructure and development', - color: 'bg-indigo-100 border-indigo-300 text-indigo-800' - } - ]; - }; - - // API functions - const fetchCampaignData = async (campaignId: string): Promise => { - const response = await fetch(`/api/v1/campaigns/${campaignId}`); - if (!response.ok) throw new Error('Failed to fetch campaign'); - const apiCampaign: ApiCampaign = await response.json(); - - return { - title: apiCampaign.name, - startDate: apiCampaign.starts_at.split('T')[0], // Convert ISO to YYYY-MM-DD - endDate: apiCampaign.ends_at.split('T')[0] - }; - }; - - const fetchCampaignRoles = async (campaignId: string): Promise => { - const response = await fetch(`/api/v1/campaigns/${campaignId}/roles`); - if (!response.ok) throw new Error('Failed to fetch roles'); - const apiRoles: ApiRole[] = await response.json(); - - // Color palette for roles - const colors = [ - 'bg-purple-100 border-purple-300 text-purple-800', - 'bg-blue-100 border-blue-300 text-blue-800', - 'bg-green-100 border-green-300 text-green-800', - 'bg-orange-100 border-orange-300 text-orange-800', - 'bg-pink-100 border-pink-300 text-pink-800', - 'bg-indigo-100 border-indigo-300 text-indigo-800', - 'bg-red-100 border-red-300 text-red-800', - 'bg-yellow-100 border-yellow-300 text-yellow-800' - ]; - - return apiRoles.map((role, index) => ({ - name: role.name, - description: role.description, - color: colors[index % colors.length] - })); - }; - - // Format date range helper - const formatDateRange = (startDate: string, endDate: string): string => { - const start = new Date(startDate); - const end = new Date(endDate); - - const options: Intl.DateTimeFormatOptions = { - day: 'numeric', - month: 'short', - year: 'numeric' - }; - - const startFormatted = start.toLocaleDateString('en-AU', options); - const endFormatted = end.toLocaleDateString('en-AU', options); - - return `${startFormatted} - ${endFormatted}`; - }; - - // Load data on component mount or when campaignId changes - useEffect(() => { - const loadData = async () => { - setIsLoading(true); - try { - console.log('Fetching data for campaign:', activeCampaignId); - - const [campaign, campaignRoles] = await Promise.all([ - fetchCampaignData(activeCampaignId), - fetchCampaignRoles(activeCampaignId) - ]); - - setCampaignData(campaign); - setRoles(campaignRoles); - console.log('Successfully loaded campaign data from API'); - - } catch (error) { - console.warn('Failed to load campaign data from API, using fallback:', error); - - // Use fallback data - setCampaignData(getFallbackCampaignData(activeCampaignId)); - setRoles(getFallbackRoles()); - } finally { - setIsLoading(false); - } - }; - - loadData(); - }, [activeCampaignId]); - - const handleAnswerSubmit = (questionId: string, value: any) => { - setAnswers((prev) => ({ - ...prev, - [questionId]: value, - })); - }; + const setAnswer = (questionId: string, value: unknown) => { + setAnswers((prev) => ({ ...prev, [questionId]: value })); + }; - const sampleOptions = [ - { id: 1, label: "Option 1" }, - { id: 2, label: "Option 2" }, - { id: 3, label: "Option 3" }, - { id: 4, label: "Option 4" }, - ]; + const renderQuestion = (q: QuestionResponse) => { + const options = (q.question_type === "MultiChoice" || q.question_type === "MultiSelect" || q.question_type === "DropDown" || q.question_type === "Ranking") + ? (q.data.options.map((o) => ({ id: o.id, label: o.text })) ?? []) + : []; + const idStr = String(q.id); - // Show loading state - if (isLoading) { + switch (q.question_type) { + case "ShortAnswer": return ( -
-
-
-

Loading campaign data...

-
-
+ setAnswer(qid, val)} + /> ); + case "DropDown": + return ( + setAnswer(qid, val)} + /> + ); + case "MultiChoice": + return ( + setAnswer(qid, val)} + /> + ); + case "MultiSelect": + return ( + ) ?? []} + onSubmit={(qid, val) => setAnswer(qid, val)} + /> + ); + case "Ranking": + return ( + ) ?? []} + onSubmit={(qid, val) => setAnswer(qid, val)} + /> + ); + default: + return null; } + }; - // Use fallback if no data loaded - const currentCampaign = campaignData || getFallbackCampaignData(activeCampaignId); - const currentRoles = roles.length > 0 ? roles : getFallbackRoles(); - + if (loading || !campaign) { return ( -
-
- {/* Campaign Warning Banner */} - {showCampaignWarning && ( -
-
-
- - - -
-
-

- Demo Mode -

-
-

- No campaign ID found in URL. Using default campaign ID "{activeCampaignId}" for testing. -
- Expected URL format: /campaign/[campaignId]/apply -

-
-
- -
-
-
-
- )} - - {/* Header - NOW DYNAMIC */} -
-

- {currentCampaign.title} -

-

- {formatDateRange(currentCampaign.startDate, currentCampaign.endDate)} -

-
- -
- {/* Sidebar - NOW DYNAMIC ROLES */} -
-

Available Roles

-
- {currentRoles.map((role) => ( -
setSelectedRole(role.name)} - > -
-

{role.name}

- -
-

{role.description}

-
- ))} -
-
- - {/* Main Content */} -
- {/* Tabs */} -
- - -
- - {/* Form Content */} - {currentTab === 'general' && ( -
-
- {/* Name */} -
-

Name

-
- - -
-
- - {/* Email */} - - - {/* zID */} - - - {/* Degree */} - - - {/* Phone Number */} - - - {/* Gender */} - - - - -
-
- )} +
+
+
+

Loading campaign data...

+
+
+ ); + } + + const activeRole = activeTab !== "general" ? roles.find((r) => String(r.id) === activeTab) : undefined; + const activeRoleQuestions = activeTab !== "general" ? questionsByRole[activeTab] ?? [] : []; + + return ( +
+
+
+

{campaign.name}

+

+ {new Date(campaign.starts_at).toLocaleDateString()} - {new Date(campaign.ends_at).toLocaleDateString()} +

+
- {currentTab === 'review' && ( -
-

Review Your Application

-
-
-

Selected Role

-

{selectedRole || 'No role selected'}

-
-
-

Form Responses

-
-                                            {JSON.stringify(answers, null, 2)}
-                                        
-
-
-
- )} +
+ {/* Sidebar – multi-select roles */} +
+

Available Roles

+
+ {roles.map((role) => { + const selected = selectedRoleIds.includes(String(role.id)); + return ( +
toggleRole(String(role.id))} + > +
+

{role.name}

+ + {selected ? "Selected" : "Select"} +
-
+ {role.description && ( +

{role.description}

+ )} +
+ ); + })}
+
+ + {/* Main Content */} +
+ {/* Tabs: General + selected roles */} +
+ + {selectedRoleIds.map((rid) => ( + + ))} +
+ + {/* General questions */} + {activeTab === "general" && ( +
+ {commonQuestions.length === 0 ? ( +

No general questions.

+ ) : ( +
{commonQuestions.map(renderQuestion)}
+ )} +
+ )} + + {/* Role-specific questions */} + {activeTab !== "general" && ( +
+

+ {activeRole?.name ?? "Role"} Questions +

+ {activeRoleQuestions.length === 0 ? ( +

No role-specific questions.

+ ) : ( +
{activeRoleQuestions.map(renderQuestion)}
+ )} +
+ )} +
- ); +
+
+ ); }; -export default DevsocRecruitmentForm; \ No newline at end of file +export default ApplicationReview; \ No newline at end of file diff --git a/frontend/src/pages/campaign/index.tsx b/frontend/src/pages/campaign/index.tsx index ce183ac8..0f1949d0 100644 --- a/frontend/src/pages/campaign/index.tsx +++ b/frontend/src/pages/campaign/index.tsx @@ -186,7 +186,7 @@ const CampaignLandingPage = () => {
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 9fd35546..2ad30b67 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -88,7 +88,7 @@ export type QuestionResponse = { //common: boolean; //max_bytes: number; required: boolean; - questionType: QuestionType; + question_type: QuestionType; data: QuestionData[]; created_at: Date; updated_at: Date; From a22487e546c17eb06014dcf0ef9670ee79e95753 Mon Sep 17 00:00:00 2001 From: Kavika Date: Thu, 14 Aug 2025 00:53:06 +1000 Subject: [PATCH 08/46] fix types for question data in fe --- frontend/src/types/api.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 2ad30b67..1ae1edec 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -89,7 +89,7 @@ export type QuestionResponse = { //max_bytes: number; required: boolean; question_type: QuestionType; - data: QuestionData[]; + data: QuestionData; created_at: Date; updated_at: Date; }; @@ -106,9 +106,9 @@ export enum QuestionType { export type QuestionData = { options: { id: string, - displayOrder: number, + display_order: number, text: string - }; + }[]; } export type QuestionInput = { From 788dbc028cf4d732a214505f148a64ae681ea78f Mon Sep 17 00:00:00 2001 From: Kavika Date: Sat, 16 Aug 2025 23:03:58 +1000 Subject: [PATCH 09/46] Update seeder.rs --- backend/database-seeding/src/seeder.rs | 79 +++++++++++++++++++++----- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/backend/database-seeding/src/seeder.rs b/backend/database-seeding/src/seeder.rs index 61ccb7fc..7358f21c 100644 --- a/backend/database-seeding/src/seeder.rs +++ b/backend/database-seeding/src/seeder.rs @@ -85,8 +85,8 @@ pub async fn seed_database(mut seeder: Seeder) { let campaign_id = Organisation::create_campaign( org_id, - "ChaosCampusRecruitment".to_string(), - "Chaos Campus Recruitment".to_string(), + "subcommittee-recruitment-2025".to_string(), + "Subcommittee Recruitment 2025".to_string(), Some("This Campaign will MAKE EVERYONE EMPLOYEED".to_string()), DateTime::::from_naive_utc_and_offset( chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap().and_hms_milli_opt(0, 0, 0, 0).unwrap(), @@ -106,10 +106,10 @@ pub async fn seed_database(mut seeder: Seeder) { let role_id_1 = Role::create( campaign_id, RoleUpdate { - name: "Software Engineer".to_string(), - description: Some("We are looking for a Software Engineer to join our team.".to_string()), + name: "Chaos".to_string(), + description: Some("We are looking for a passionate Rust developer to join our team.".to_string()), min_available: 1, - max_avaliable: 1, + max_avaliable: 10, finalised: false, }, &mut tx, @@ -120,8 +120,8 @@ pub async fn seed_database(mut seeder: Seeder) { let role_id_2 = Role::create( campaign_id, RoleUpdate { - name: "High Temperature Starch Heat Treatment Technician".to_string(), - description: Some("Just put the fries in the ...".to_string()), + name: "Notangles".to_string(), + description: Some("We hate tangles.".to_string()), min_available: 1, max_avaliable: 100, finalised: true, @@ -133,8 +133,9 @@ pub async fn seed_database(mut seeder: Seeder) { let question_id_1 = Question::create( campaign_id, - "Career History".to_string(), - Some("How many years of industry experience do you have?".to_string()), + "What year are you in?".to_string(), + None, + // Some("How many years of industry experience do you have?".to_string()), true, None, true, @@ -143,19 +144,24 @@ pub async fn seed_database(mut seeder: Seeder) { options: vec![ MultiOptionQuestionOption { id: 0, - text: "Less than 1 year".to_string(), + text: "1".to_string(), display_order: 1, }, MultiOptionQuestionOption { id: 0, - text: "2 years".to_string(), + text: "2".to_string(), display_order: 2, }, MultiOptionQuestionOption { id: 0, - text: "More than 2 years".to_string(), + text: "3".to_string(), display_order: 3, }, + MultiOptionQuestionOption { + id: 0, + text: "4+".to_string(), + display_order: 4, + }, ] }, ), @@ -167,8 +173,8 @@ pub async fn seed_database(mut seeder: Seeder) { let question_id_2 = Question::create( campaign_id, - "Technical Question (pls don't use AI)".to_string(), - Some("What is a Monad?".to_string()), + "Why do you love Rust?".to_string(), + Some("This is a special question just for Chaos applicants".to_string()), false, Some(vec![role_id_1]), true, @@ -178,6 +184,51 @@ pub async fn seed_database(mut seeder: Seeder) { ) .await.expect("Failed seeding Question 1"); + let question_id_3 = Question::create( + campaign_id, + "What languages are you familiar with?".to_string(), + Some("This is a general question for all technical roles".to_string()), + false, + Some(vec![role_id_1, role_id_2]), + true, + QuestionData::MultiSelect( + MultiOptionData { + options: vec![ + MultiOptionQuestionOption { + id: 0, + text: "Rust".to_string(), + display_order: 1, + }, + MultiOptionQuestionOption { + id: 0, + text: "Python".to_string(), + display_order: 2, + }, + MultiOptionQuestionOption { + id: 0, + text: "JavaScript".to_string(), + display_order: 3, + }, + + MultiOptionQuestionOption { + id: 0, + text: "Java".to_string(), + display_order: 4, + }, + + MultiOptionQuestionOption { + id: 0, + text: "C++".to_string(), + display_order: 5, + }, + ] + } + ), + &mut seeder.app_state.snowflake_generator, + &mut tx, + ) + .await.expect("Failed seeding Question 3"); + let application_id_1 = Application::create( campaign_id, 3, From 5932c71557d3fcde6cd15be69ccb639c9b8a5075 Mon Sep 17 00:00:00 2001 From: Kavika Date: Sat, 16 Aug 2025 23:04:09 +1000 Subject: [PATCH 10/46] fetch and show questions for selected roles --- backend/server/src/models/question.rs | 2 +- .../application_page/ApplicationForm.tsx | 32 +++-- .../pages/application_page/RolesSidebar.tsx | 2 +- frontend/src/pages/application_page/index.tsx | 53 ++++--- .../src/pages/application_review/index.tsx | 130 ++++++++++++++++++ 5 files changed, 188 insertions(+), 31 deletions(-) diff --git a/backend/server/src/models/question.rs b/backend/server/src/models/question.rs index 089c425c..9cafad1d 100644 --- a/backend/server/src/models/question.rs +++ b/backend/server/src/models/question.rs @@ -303,7 +303,7 @@ impl Question { LEFT JOIN multi_option_question_options mod ON q.id = mod.question_id AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown', 'Ranking') - WHERE q.campaign_id = $1 AND q.common = true AND qr.role_id = $2 + WHERE q.campaign_id = $1 AND q.common = false AND qr.role_id = $2 GROUP BY q.id "#, diff --git a/frontend/src/pages/application_page/ApplicationForm.tsx b/frontend/src/pages/application_page/ApplicationForm.tsx index ec89379f..cafd6481 100644 --- a/frontend/src/pages/application_page/ApplicationForm.tsx +++ b/frontend/src/pages/application_page/ApplicationForm.tsx @@ -16,6 +16,7 @@ type Props = { answers: { [question: string]: string }; setAnswer: (_question: string, _answer: string) => void; onSubmit: () => void; + loadingRoleQuestions: Set; }; const ApplicationForm = ({ roles, @@ -24,22 +25,33 @@ const ApplicationForm = ({ answers, setAnswer, onSubmit, + loadingRoleQuestions, }: Props) => (

Questions

{rolesSelected.map((role) => (

{roles.find((r) => r.id === role)?.name}

- {roleQuestions[role].map(({ id, text }) => ( -